mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-03-15 15:54:35 -04:00
Compare commits
5 Commits
v5-queue
...
newznab-su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78913a3e9f | ||
|
|
7fdc4d6638 | ||
|
|
309b55fe38 | ||
|
|
d6f265c7b5 | ||
|
|
e757dca038 |
@@ -1,113 +0,0 @@
|
||||
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) ?? [];
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
import React from 'react';
|
||||
import Episode from 'Episode/Episode';
|
||||
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
|
||||
import Series from 'Series/Series';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
interface EpisodeCellContentProps {
|
||||
episodes: Episode[];
|
||||
isFullSeason: boolean;
|
||||
seasonNumber?: number;
|
||||
series?: Series;
|
||||
}
|
||||
|
||||
export default function EpisodeCellContent({
|
||||
episodes,
|
||||
isFullSeason,
|
||||
seasonNumber,
|
||||
series,
|
||||
}: EpisodeCellContentProps) {
|
||||
if (episodes.length === 0) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (isFullSeason && seasonNumber != null) {
|
||||
return translate('SeasonNumberToken', { seasonNumber });
|
||||
}
|
||||
|
||||
if (episodes.length === 1) {
|
||||
const episode = episodes[0];
|
||||
|
||||
return (
|
||||
<SeasonEpisodeNumber
|
||||
seasonNumber={episode.seasonNumber}
|
||||
episodeNumber={episode.episodeNumber}
|
||||
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
|
||||
seriesType={series?.seriesType}
|
||||
alternateTitles={series?.alternateTitles}
|
||||
sceneSeasonNumber={episode.sceneSeasonNumber}
|
||||
sceneEpisodeNumber={episode.sceneEpisodeNumber}
|
||||
sceneAbsoluteEpisodeNumber={episode.sceneAbsoluteEpisodeNumber}
|
||||
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const firstEpisode = episodes[0];
|
||||
const lastEpisode = episodes[episodes.length - 1];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeasonEpisodeNumber
|
||||
seasonNumber={firstEpisode.seasonNumber}
|
||||
episodeNumber={firstEpisode.episodeNumber}
|
||||
absoluteEpisodeNumber={firstEpisode.absoluteEpisodeNumber}
|
||||
seriesType={series?.seriesType}
|
||||
alternateTitles={series?.alternateTitles}
|
||||
sceneSeasonNumber={firstEpisode.sceneSeasonNumber}
|
||||
sceneEpisodeNumber={firstEpisode.sceneEpisodeNumber}
|
||||
sceneAbsoluteEpisodeNumber={firstEpisode.sceneAbsoluteEpisodeNumber}
|
||||
unverifiedSceneNumbering={firstEpisode.unverifiedSceneNumbering}
|
||||
/>
|
||||
{' - '}
|
||||
<SeasonEpisodeNumber
|
||||
seasonNumber={lastEpisode.seasonNumber}
|
||||
episodeNumber={lastEpisode.episodeNumber}
|
||||
absoluteEpisodeNumber={lastEpisode.absoluteEpisodeNumber}
|
||||
seriesType={series?.seriesType}
|
||||
alternateTitles={series?.alternateTitles}
|
||||
sceneSeasonNumber={lastEpisode.sceneSeasonNumber}
|
||||
sceneEpisodeNumber={lastEpisode.sceneEpisodeNumber}
|
||||
sceneAbsoluteEpisodeNumber={lastEpisode.sceneAbsoluteEpisodeNumber}
|
||||
unverifiedSceneNumbering={lastEpisode.unverifiedSceneNumbering}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
.multiple {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.episodeNumber {
|
||||
margin-right: 8px;
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'episodeNumber': string;
|
||||
'multiple': string;
|
||||
'row': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -1,66 +0,0 @@
|
||||
import React from 'react';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import Episode from 'Episode/Episode';
|
||||
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
|
||||
import Series from 'Series/Series';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EpisodeTitleCellContent.css';
|
||||
|
||||
interface EpisodeTitleCellContentProps {
|
||||
episodes: Episode[];
|
||||
series?: Series;
|
||||
}
|
||||
|
||||
export default function EpisodeTitleCellContent({
|
||||
episodes,
|
||||
series,
|
||||
}: EpisodeTitleCellContentProps) {
|
||||
if (episodes.length === 0 || !series) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (episodes.length === 1) {
|
||||
const episode = episodes[0];
|
||||
|
||||
return (
|
||||
<EpisodeTitleLink
|
||||
episodeId={episode.id}
|
||||
seriesId={series.id}
|
||||
episodeTitle={episode.title}
|
||||
episodeEntity="episodes"
|
||||
showOpenSeriesButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
anchor={
|
||||
<span className={styles.multiple}>{translate('MultipleEpisodes')}</span>
|
||||
}
|
||||
title={translate('EpisodeTitles')}
|
||||
body={
|
||||
<>
|
||||
{episodes.map((episode) => {
|
||||
return (
|
||||
<div key={episode.id} className={styles.row}>
|
||||
<div className={styles.episodeNumber}>
|
||||
{episode.episodeNumber}
|
||||
</div>
|
||||
|
||||
<EpisodeTitleLink
|
||||
episodeId={episode.id}
|
||||
seriesId={series.id}
|
||||
episodeTitle={episode.title}
|
||||
episodeEntity="episodes"
|
||||
showOpenSeriesButton={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
}
|
||||
position="right"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import React, {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
@@ -21,15 +22,28 @@ import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||
import TablePager from 'Components/Table/TablePager';
|
||||
import usePaging from 'Components/Table/usePaging';
|
||||
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
|
||||
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
|
||||
import useSelectState from 'Helpers/Hooks/useSelectState';
|
||||
import { align, icons, kinds } from 'Helpers/Props';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
|
||||
import {
|
||||
clearQueue,
|
||||
fetchQueue,
|
||||
gotoQueuePage,
|
||||
grabQueueItems,
|
||||
removeQueueItems,
|
||||
setQueueFilter,
|
||||
setQueueSort,
|
||||
setQueueTableOption,
|
||||
} from 'Store/Actions/queueActions';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import { CheckInputChanged } from 'typings/inputs';
|
||||
import { SelectStateInputProps } from 'typings/props';
|
||||
import QueueItem from 'typings/Queue';
|
||||
import { TableOptionsChangePayload } from 'typings/Table';
|
||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||
import {
|
||||
@@ -40,51 +54,33 @@ import translate from 'Utilities/String/translate';
|
||||
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
||||
import QueueFilterModal from './QueueFilterModal';
|
||||
import QueueOptions from './QueueOptions';
|
||||
import {
|
||||
setQueueOption,
|
||||
setQueueOptions,
|
||||
useQueueOptions,
|
||||
} from './queueOptionsStore';
|
||||
import QueueRow from './QueueRow';
|
||||
import RemoveQueueItemModal from './RemoveQueueItemModal';
|
||||
import useQueueStatus from './Status/useQueueStatus';
|
||||
import useQueue, {
|
||||
useFilters,
|
||||
useGrabQueueItems,
|
||||
useRemoveQueueItems,
|
||||
} from './useQueue';
|
||||
|
||||
const DEFAULT_DATA = {
|
||||
records: [],
|
||||
totalPages: 0,
|
||||
totalRecords: 0,
|
||||
};
|
||||
import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal';
|
||||
import createQueueStatusSelector from './Status/createQueueStatusSelector';
|
||||
|
||||
function Queue() {
|
||||
const requestCurrentPage = useCurrentPage();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
isFetching,
|
||||
isFetched,
|
||||
isLoading,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
columns,
|
||||
selectedFilterKey,
|
||||
filters,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
page,
|
||||
goToPage,
|
||||
refetch,
|
||||
} = useQueue();
|
||||
pageSize,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
isGrabbing,
|
||||
isRemoving,
|
||||
} = useSelector((state: AppState) => state.queue.paged);
|
||||
|
||||
const { records, totalPages = 0, totalRecords } = data ?? DEFAULT_DATA;
|
||||
|
||||
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
|
||||
useQueueOptions();
|
||||
|
||||
const filters = useFilters();
|
||||
|
||||
const { isRemoving, removeQueueItems } = useRemoveQueueItems();
|
||||
const { isGrabbing, grabQueueItems } = useGrabQueueItems();
|
||||
|
||||
const { count } = useQueueStatus();
|
||||
const { count } = useSelector(createQueueStatusSelector());
|
||||
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
|
||||
useSelector(createEpisodesFetchingSelector());
|
||||
const customFilters = useSelector(createCustomFiltersSelector('queue'));
|
||||
@@ -104,46 +100,41 @@ function Queue() {
|
||||
}, [selectedState]);
|
||||
|
||||
const isPendingSelected = useMemo(() => {
|
||||
return records.some((item) => {
|
||||
return items.some((item) => {
|
||||
return selectedIds.indexOf(item.id) > -1 && item.status === 'delay';
|
||||
});
|
||||
}, [records, selectedIds]);
|
||||
}, [items, selectedIds]);
|
||||
|
||||
const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const isRefreshing =
|
||||
isLoading || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
|
||||
isFetching || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
|
||||
const isAllPopulated =
|
||||
isFetched &&
|
||||
(isEpisodesPopulated ||
|
||||
!records.length ||
|
||||
records.every((e) => !e.episodeIds?.length));
|
||||
isPopulated &&
|
||||
(isEpisodesPopulated || !items.length || items.every((e) => !e.episodeId));
|
||||
const hasError = error || episodesError;
|
||||
const selectedCount = selectedIds.length;
|
||||
const disableSelectedActions = selectedCount === 0;
|
||||
|
||||
const handleSelectAllChange = useCallback(
|
||||
({ value }: CheckInputChanged) => {
|
||||
setSelectState({
|
||||
type: value ? 'selectAll' : 'unselectAll',
|
||||
items: records,
|
||||
});
|
||||
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
|
||||
},
|
||||
[records, setSelectState]
|
||||
[items, setSelectState]
|
||||
);
|
||||
|
||||
const handleSelectedChange = useCallback(
|
||||
({ id, value, shiftKey = false }: SelectStateInputProps) => {
|
||||
setSelectState({
|
||||
type: 'toggleSelected',
|
||||
items: records,
|
||||
items,
|
||||
id,
|
||||
isSelected: value,
|
||||
shiftKey,
|
||||
});
|
||||
},
|
||||
[records, setSelectState]
|
||||
[items, setSelectState]
|
||||
);
|
||||
|
||||
const handleRefreshPress = useCallback(() => {
|
||||
@@ -159,60 +150,93 @@ function Queue() {
|
||||
}, []);
|
||||
|
||||
const handleGrabSelectedPress = useCallback(() => {
|
||||
grabQueueItems({ ids: selectedIds });
|
||||
}, [selectedIds, grabQueueItems]);
|
||||
dispatch(grabQueueItems({ ids: selectedIds }));
|
||||
}, [selectedIds, dispatch]);
|
||||
|
||||
const handleRemoveSelectedPress = useCallback(() => {
|
||||
shouldBlockRefresh.current = true;
|
||||
setIsConfirmRemoveModalOpen(true);
|
||||
}, [setIsConfirmRemoveModalOpen]);
|
||||
|
||||
const handleRemoveSelectedConfirmed = useCallback(() => {
|
||||
shouldBlockRefresh.current = false;
|
||||
removeQueueItems({ ids: selectedIds });
|
||||
setIsConfirmRemoveModalOpen(false);
|
||||
}, [selectedIds, setIsConfirmRemoveModalOpen, removeQueueItems]);
|
||||
const handleRemoveSelectedConfirmed = useCallback(
|
||||
(payload: RemovePressProps) => {
|
||||
shouldBlockRefresh.current = false;
|
||||
dispatch(removeQueueItems({ ids: selectedIds, ...payload }));
|
||||
setIsConfirmRemoveModalOpen(false);
|
||||
},
|
||||
[selectedIds, setIsConfirmRemoveModalOpen, dispatch]
|
||||
);
|
||||
|
||||
const handleConfirmRemoveModalClose = useCallback(() => {
|
||||
shouldBlockRefresh.current = false;
|
||||
setIsConfirmRemoveModalOpen(false);
|
||||
}, [setIsConfirmRemoveModalOpen]);
|
||||
|
||||
const {
|
||||
handleFirstPagePress,
|
||||
handlePreviousPagePress,
|
||||
handleNextPagePress,
|
||||
handleLastPagePress,
|
||||
handlePageSelect,
|
||||
} = usePaging({
|
||||
page,
|
||||
totalPages,
|
||||
gotoPage: gotoQueuePage,
|
||||
});
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(selectedFilterKey: string | number) => {
|
||||
setQueueOption('selectedFilterKey', selectedFilterKey);
|
||||
dispatch(setQueueFilter({ selectedFilterKey }));
|
||||
},
|
||||
[]
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleSortPress = useCallback((sortKey: string) => {
|
||||
setQueueOption('sortKey', sortKey);
|
||||
}, []);
|
||||
const handleSortPress = useCallback(
|
||||
(sortKey: string) => {
|
||||
dispatch(setQueueSort({ sortKey }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleTableOptionChange = useCallback(
|
||||
(payload: TableOptionsChangePayload) => {
|
||||
setQueueOptions(payload);
|
||||
dispatch(setQueueTableOption(payload));
|
||||
|
||||
if (payload.pageSize) {
|
||||
goToPage(1);
|
||||
dispatch(gotoQueuePage({ page: 1 }));
|
||||
}
|
||||
},
|
||||
[goToPage]
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const episodeIds = selectUniqueIds(records, 'episodeIds');
|
||||
if (requestCurrentPage) {
|
||||
dispatch(fetchQueue());
|
||||
} else {
|
||||
dispatch(gotoQueuePage({ page: 1 }));
|
||||
}
|
||||
|
||||
return () => {
|
||||
dispatch(clearQueue());
|
||||
};
|
||||
}, [requestCurrentPage, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const episodeIds = selectUniqueIds<QueueItem, number | undefined>(
|
||||
items,
|
||||
'episodeId'
|
||||
);
|
||||
|
||||
if (episodeIds.length) {
|
||||
dispatch(fetchEpisodes({ episodeIds }));
|
||||
} else {
|
||||
dispatch(clearEpisodes());
|
||||
}
|
||||
}, [records, dispatch]);
|
||||
}, [items, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const repopulate = () => {
|
||||
refetch();
|
||||
dispatch(fetchQueue());
|
||||
};
|
||||
|
||||
registerPagePopulator(repopulate);
|
||||
@@ -220,7 +244,7 @@ function Queue() {
|
||||
return () => {
|
||||
unregisterPagePopulator(repopulate);
|
||||
};
|
||||
}, [refetch]);
|
||||
}, [dispatch]);
|
||||
|
||||
if (!shouldBlockRefresh.current) {
|
||||
currentQueue.current = (
|
||||
@@ -231,7 +255,7 @@ function Queue() {
|
||||
<Alert kind={kinds.DANGER}>{translate('QueueLoadError')}</Alert>
|
||||
) : null}
|
||||
|
||||
{isAllPopulated && !hasError && !records.length ? (
|
||||
{isAllPopulated && !hasError && !items.length ? (
|
||||
<Alert kind={kinds.INFO}>
|
||||
{selectedFilterKey !== 'all' && count > 0
|
||||
? translate('QueueFilterHasNoItems')
|
||||
@@ -239,7 +263,7 @@ function Queue() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{isAllPopulated && !hasError && !!records.length ? (
|
||||
{isAllPopulated && !hasError && !!items.length ? (
|
||||
<div>
|
||||
<Table
|
||||
selectAll={true}
|
||||
@@ -255,10 +279,11 @@ function Queue() {
|
||||
onSortPress={handleSortPress}
|
||||
>
|
||||
<TableBody>
|
||||
{records.map((item) => {
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<QueueRow
|
||||
key={item.id}
|
||||
episodeId={item.episodeId}
|
||||
isSelected={selectedState[item.id]}
|
||||
columns={columns}
|
||||
{...item}
|
||||
@@ -277,7 +302,11 @@ function Queue() {
|
||||
totalPages={totalPages}
|
||||
totalRecords={totalRecords}
|
||||
isFetching={isFetching}
|
||||
onPageSelect={goToPage}
|
||||
onFirstPagePress={handleFirstPagePress}
|
||||
onPreviousPagePress={handlePreviousPagePress}
|
||||
onNextPagePress={handleNextPagePress}
|
||||
onLastPagePress={handleLastPagePress}
|
||||
onPageSelect={handlePageSelect}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -348,7 +377,7 @@ function Queue() {
|
||||
canChangeCategory={
|
||||
isConfirmRemoveModalOpen &&
|
||||
selectedIds.every((id) => {
|
||||
const item = records.find((i) => i.id === id);
|
||||
const item = items.find((i) => i.id === id);
|
||||
|
||||
return !!(item && item.downloadClientHasPostImportCategory);
|
||||
})
|
||||
@@ -356,7 +385,7 @@ function Queue() {
|
||||
canIgnore={
|
||||
isConfirmRemoveModalOpen &&
|
||||
selectedIds.every((id) => {
|
||||
const item = records.find((i) => i.id === id);
|
||||
const item = items.find((i) => i.id === id);
|
||||
|
||||
return !!(item && item.seriesId && item.episodeId);
|
||||
})
|
||||
@@ -364,7 +393,7 @@ function Queue() {
|
||||
isPending={
|
||||
isConfirmRemoveModalOpen &&
|
||||
selectedIds.every((id) => {
|
||||
const item = records.find((i) => i.id === id);
|
||||
const item = items.find((i) => i.id === id);
|
||||
|
||||
if (!item) {
|
||||
return false;
|
||||
|
||||
@@ -14,7 +14,7 @@ import styles from './QueueDetails.css';
|
||||
interface QueueDetailsProps {
|
||||
title: string;
|
||||
size: number;
|
||||
sizeLeft: number;
|
||||
sizeleft: number;
|
||||
estimatedCompletionTime?: string;
|
||||
status: string;
|
||||
trackedDownloadState?: QueueTrackedDownloadState;
|
||||
@@ -28,7 +28,7 @@ function QueueDetails(props: QueueDetailsProps) {
|
||||
const {
|
||||
title,
|
||||
size,
|
||||
sizeLeft,
|
||||
sizeleft,
|
||||
status,
|
||||
trackedDownloadState = 'downloading',
|
||||
trackedDownloadStatus = 'ok',
|
||||
@@ -37,7 +37,7 @@ function QueueDetails(props: QueueDetailsProps) {
|
||||
progressBar,
|
||||
} = props;
|
||||
|
||||
const progress = 100 - (sizeLeft / size) * 100;
|
||||
const progress = 100 - (sizeleft / size) * 100;
|
||||
const isDownloading = status === 'downloading';
|
||||
const isPaused = status === 'paused';
|
||||
const hasWarning = trackedDownloadStatus === 'warning';
|
||||
|
||||
@@ -1,26 +1,49 @@
|
||||
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 { setQueueOption } from './queueOptionsStore';
|
||||
import useQueue, { FILTER_BUILDER } from './useQueue';
|
||||
import { setQueueFilter } from 'Store/Actions/queueActions';
|
||||
|
||||
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>;
|
||||
|
||||
export default function QueueFilterModal(props: QueueFilterModalProps) {
|
||||
const { data } = useQueue();
|
||||
const sectionItems = useSelector(createQueueSelector());
|
||||
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
|
||||
const customFilterType = 'queue';
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const dispatchSetFilter = useCallback(
|
||||
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
|
||||
setQueueOption('selectedFilterKey', selectedFilterKey);
|
||||
(payload: unknown) => {
|
||||
dispatch(setQueueFilter(payload));
|
||||
},
|
||||
[]
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterModal
|
||||
{...props}
|
||||
sectionItems={data?.records ?? []}
|
||||
filterBuilderProps={FILTER_BUILDER}
|
||||
sectionItems={sectionItems}
|
||||
filterBuilderProps={filterBuilderProps}
|
||||
customFilterType={customFilterType}
|
||||
dispatchSetFilter={dispatchSetFilter}
|
||||
/>
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import { OptionChanged } from 'Helpers/Hooks/useOptionsStore';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import { gotoQueuePage, setQueueOption } from 'Store/Actions/queueActions';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import {
|
||||
QueueOptions as QueueOptionsType,
|
||||
setQueueOption,
|
||||
useQueueOption,
|
||||
} from './queueOptionsStore';
|
||||
import useQueue from './useQueue';
|
||||
|
||||
function QueueOptions() {
|
||||
const includeUnknownSeriesItems = useQueueOption('includeUnknownSeriesItems');
|
||||
const { goToPage } = useQueue();
|
||||
const dispatch = useDispatch();
|
||||
const { includeUnknownSeriesItems } = useSelector(
|
||||
(state: AppState) => state.queue.options
|
||||
);
|
||||
|
||||
const handleOptionChange = useCallback(
|
||||
({ name, value }: OptionChanged<QueueOptionsType>) => {
|
||||
setQueueOption(name, value);
|
||||
({ name, value }: InputChanged<boolean>) => {
|
||||
dispatch(
|
||||
setQueueOption({
|
||||
[name]: value,
|
||||
})
|
||||
);
|
||||
|
||||
if (name === 'includeUnknownSeriesItems') {
|
||||
goToPage(1);
|
||||
dispatch(gotoQueuePage({ page: 1 }));
|
||||
}
|
||||
},
|
||||
[goToPage]
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -36,7 +39,6 @@ function QueueOptions() {
|
||||
name="includeUnknownSeriesItems"
|
||||
value={includeUnknownSeriesItems}
|
||||
helpText={translate('ShowUnknownSeriesItemsHelpText')}
|
||||
// @ts-expect-error - The typing for inputs needs more work
|
||||
onChange={handleOptionChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
|
||||
import { Error } from 'App/State/AppSectionState';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||
import ProgressBar from 'Components/ProgressBar';
|
||||
@@ -14,13 +15,16 @@ import DownloadProtocol from 'DownloadClient/DownloadProtocol';
|
||||
import EpisodeFormats from 'Episode/EpisodeFormats';
|
||||
import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
||||
import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||
import useEpisodes from 'Episode/useEpisodes';
|
||||
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
|
||||
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
|
||||
import useEpisode from 'Episode/useEpisode';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||
import Language from 'Language/Language';
|
||||
import { QualityModel } from 'Quality/Quality';
|
||||
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
import { SelectStateInputProps } from 'typings/props';
|
||||
@@ -32,18 +36,15 @@ import {
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EpisodeCellContent from './EpisodeCellContent';
|
||||
import EpisodeTitleCellContent from './EpisodeTitleCellContent';
|
||||
import QueueStatusCell from './QueueStatusCell';
|
||||
import RemoveQueueItemModal from './RemoveQueueItemModal';
|
||||
import TimeLeftCell from './TimeLeftCell';
|
||||
import { useGrabQueueItem, useRemoveQueueItem } from './useQueue';
|
||||
import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal';
|
||||
import TimeleftCell from './TimeleftCell';
|
||||
import styles from './QueueRow.css';
|
||||
|
||||
interface QueueRowProps {
|
||||
id: number;
|
||||
seriesId?: number;
|
||||
episodeIds: number[];
|
||||
episodeId?: number;
|
||||
downloadId?: string;
|
||||
title: string;
|
||||
status: string;
|
||||
@@ -57,16 +58,16 @@ interface QueueRowProps {
|
||||
customFormatScore: number;
|
||||
protocol: DownloadProtocol;
|
||||
indexer?: string;
|
||||
isFullSeason: boolean;
|
||||
seasonNumbers: number[];
|
||||
outputPath?: string;
|
||||
downloadClient?: string;
|
||||
downloadClientHasPostImportCategory?: boolean;
|
||||
estimatedCompletionTime?: string;
|
||||
added?: string;
|
||||
timeLeft?: string;
|
||||
timeleft?: string;
|
||||
size: number;
|
||||
sizeLeft: number;
|
||||
sizeleft: number;
|
||||
isGrabbing?: boolean;
|
||||
grabError?: Error;
|
||||
isRemoving?: boolean;
|
||||
isSelected?: boolean;
|
||||
columns: Column[];
|
||||
@@ -78,7 +79,7 @@ function QueueRow(props: QueueRowProps) {
|
||||
const {
|
||||
id,
|
||||
seriesId,
|
||||
episodeIds,
|
||||
episodeId,
|
||||
downloadId,
|
||||
title,
|
||||
status,
|
||||
@@ -96,25 +97,25 @@ function QueueRow(props: QueueRowProps) {
|
||||
downloadClient,
|
||||
downloadClientHasPostImportCategory,
|
||||
estimatedCompletionTime,
|
||||
isFullSeason,
|
||||
seasonNumbers,
|
||||
added,
|
||||
timeLeft,
|
||||
timeleft,
|
||||
size,
|
||||
sizeLeft,
|
||||
sizeleft,
|
||||
isGrabbing = false,
|
||||
grabError,
|
||||
isRemoving = false,
|
||||
isSelected,
|
||||
columns,
|
||||
onSelectedChange,
|
||||
onQueueRowModalOpenOrClose,
|
||||
} = props;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const series = useSeries(seriesId);
|
||||
const episodes = useEpisodes(episodeIds, 'episodes');
|
||||
const episode = useEpisode(episodeId, 'episodes');
|
||||
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
const { removeQueueItem, isRemoving } = useRemoveQueueItem(id);
|
||||
const { grabQueueItem, isGrabbing, grabError } = useGrabQueueItem(id);
|
||||
|
||||
const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] =
|
||||
useState(false);
|
||||
@@ -123,8 +124,8 @@ function QueueRow(props: QueueRowProps) {
|
||||
useState(false);
|
||||
|
||||
const handleGrabPress = useCallback(() => {
|
||||
grabQueueItem();
|
||||
}, [grabQueueItem]);
|
||||
dispatch(grabQueueItem({ id }));
|
||||
}, [id, dispatch]);
|
||||
|
||||
const handleInteractiveImportPress = useCallback(() => {
|
||||
onQueueRowModalOpenOrClose(true);
|
||||
@@ -141,22 +142,21 @@ function QueueRow(props: QueueRowProps) {
|
||||
setIsRemoveQueueItemModalOpen(true);
|
||||
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
|
||||
|
||||
const handleRemoveQueueItemModalConfirmed = useCallback(() => {
|
||||
onQueueRowModalOpenOrClose(false);
|
||||
removeQueueItem();
|
||||
setIsRemoveQueueItemModalOpen(false);
|
||||
}, [
|
||||
setIsRemoveQueueItemModalOpen,
|
||||
removeQueueItem,
|
||||
onQueueRowModalOpenOrClose,
|
||||
]);
|
||||
const handleRemoveQueueItemModalConfirmed = useCallback(
|
||||
(payload: RemovePressProps) => {
|
||||
onQueueRowModalOpenOrClose(false);
|
||||
dispatch(removeQueueItem({ id, ...payload }));
|
||||
setIsRemoveQueueItemModalOpen(false);
|
||||
},
|
||||
[id, setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose, dispatch]
|
||||
);
|
||||
|
||||
const handleRemoveQueueItemModalClose = useCallback(() => {
|
||||
onQueueRowModalOpenOrClose(false);
|
||||
setIsRemoveQueueItemModalOpen(false);
|
||||
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
|
||||
|
||||
const progress = 100 - (sizeLeft / size) * 100;
|
||||
const progress = 100 - (sizeleft / size) * 100;
|
||||
const showInteractiveImport =
|
||||
status === 'completed' && trackedDownloadStatus === 'warning';
|
||||
const isPending =
|
||||
@@ -209,12 +209,23 @@ function QueueRow(props: QueueRowProps) {
|
||||
if (name === 'episode') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
<EpisodeCellContent
|
||||
episodes={episodes}
|
||||
isFullSeason={isFullSeason}
|
||||
seasonNumber={seasonNumbers[0]}
|
||||
series={series}
|
||||
/>
|
||||
{episode ? (
|
||||
<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}
|
||||
/>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
@@ -222,37 +233,27 @@ function QueueRow(props: QueueRowProps) {
|
||||
if (name === 'episodes.title') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
<EpisodeTitleCellContent episodes={episodes} series={series} />
|
||||
{series && episode ? (
|
||||
<EpisodeTitleLink
|
||||
episodeId={episode.id}
|
||||
seriesId={series.id}
|
||||
episodeTitle={episode.title}
|
||||
episodeEntity="episodes"
|
||||
showOpenSeriesButton={true}
|
||||
/>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'episodes.airDateUtc') {
|
||||
if (episodes.length === 0) {
|
||||
return <TableRowCell key={name}>-</TableRowCell>;
|
||||
if (episode) {
|
||||
return <RelativeDateCell key={name} date={episode.airDateUtc} />;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
return <TableRowCell key={name}>-</TableRowCell>;
|
||||
}
|
||||
|
||||
if (name === 'languages') {
|
||||
@@ -324,13 +325,13 @@ function QueueRow(props: QueueRowProps) {
|
||||
|
||||
if (name === 'estimatedCompletionTime') {
|
||||
return (
|
||||
<TimeLeftCell
|
||||
<TimeleftCell
|
||||
key={name}
|
||||
status={status}
|
||||
estimatedCompletionTime={estimatedCompletionTime}
|
||||
timeLeft={timeLeft}
|
||||
timeleft={timeleft}
|
||||
size={size}
|
||||
sizeLeft={sizeLeft}
|
||||
sizeleft={sizeleft}
|
||||
showRelativeDates={showRelativeDates}
|
||||
shortDateFormat={shortDateFormat}
|
||||
timeFormat={timeFormat}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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 FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
@@ -9,16 +11,19 @@ import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { OptionChanged } from 'Helpers/Hooks/useOptionsStore';
|
||||
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 {
|
||||
QueueOptions,
|
||||
setQueueOption,
|
||||
useQueueOption,
|
||||
} from './queueOptionsStore';
|
||||
import styles from './RemoveQueueItemModal.css';
|
||||
|
||||
export interface RemovePressProps {
|
||||
remove: boolean;
|
||||
changeCategory: boolean;
|
||||
blocklist: boolean;
|
||||
skipRedownload: boolean;
|
||||
}
|
||||
|
||||
interface RemoveQueueItemModalProps {
|
||||
isOpen: boolean;
|
||||
sourceTitle?: string;
|
||||
@@ -26,7 +31,7 @@ interface RemoveQueueItemModalProps {
|
||||
canIgnore: boolean;
|
||||
isPending: boolean;
|
||||
selectedCount?: number;
|
||||
onRemovePress(): void;
|
||||
onRemovePress(props: RemovePressProps): void;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
@@ -42,8 +47,13 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
onModalClose,
|
||||
} = props;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const multipleSelected = selectedCount && selectedCount > 1;
|
||||
const { removalMethod, blocklistMethod } = useQueueOption('removalOptions');
|
||||
|
||||
const { removalMethod, blocklistMethod } = useSelector(
|
||||
(state: AppState) => state.queue.removalOptions
|
||||
);
|
||||
|
||||
const { title, message } = useMemo(() => {
|
||||
if (!selectedCount) {
|
||||
@@ -128,19 +138,20 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
}, [isPending, multipleSelected]);
|
||||
|
||||
const handleRemovalOptionInputChange = useCallback(
|
||||
({ name, value }: OptionChanged<QueueOptions['removalOptions']>) => {
|
||||
setQueueOption('removalOptions', {
|
||||
removalMethod,
|
||||
blocklistMethod,
|
||||
[name]: value,
|
||||
});
|
||||
({ name, value }: InputChanged) => {
|
||||
dispatch(setQueueRemovalOption({ [name]: value }));
|
||||
},
|
||||
[removalMethod, blocklistMethod]
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleConfirmRemove = useCallback(() => {
|
||||
onRemovePress();
|
||||
}, [onRemovePress]);
|
||||
onRemovePress({
|
||||
remove: removalMethod === 'removeFromClient',
|
||||
changeCategory: removalMethod === 'changeCategory',
|
||||
blocklist: blocklistMethod !== 'doNotBlocklist',
|
||||
skipRedownload: blocklistMethod === 'blocklistOnly',
|
||||
});
|
||||
}, [removalMethod, blocklistMethod, onRemovePress]);
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
onModalClose();
|
||||
@@ -167,7 +178,6 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
helpTextWarning={translate(
|
||||
'RemoveQueueItemRemovalMethodHelpTextWarning'
|
||||
)}
|
||||
// @ts-expect-error - The typing for inputs needs more work
|
||||
onChange={handleRemovalOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
@@ -186,7 +196,6 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
value={blocklistMethod}
|
||||
values={blocklistMethodOptions}
|
||||
helpText={translate('BlocklistReleaseHelpText')}
|
||||
// @ts-expect-error - The typing for inputs needs more work
|
||||
onChange={handleRemovalOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
|
||||
import useQueueStatus from './useQueueStatus';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { fetchQueueStatus } from 'Store/Actions/queueActions';
|
||||
import createQueueStatusSelector from './createQueueStatusSelector';
|
||||
|
||||
function QueueStatus() {
|
||||
const { errors, warnings, count } = useQueueStatus();
|
||||
const dispatch = useDispatch();
|
||||
const { isConnected, isReconnecting } = useSelector(
|
||||
(state: AppState) => state.app
|
||||
);
|
||||
const { isPopulated, count, errors, warnings } = useSelector(
|
||||
createQueueStatusSelector()
|
||||
);
|
||||
|
||||
const wasReconnecting = usePrevious(isReconnecting);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPopulated) {
|
||||
dispatch(fetchQueueStatus());
|
||||
}
|
||||
}, [isPopulated, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected && wasReconnecting) {
|
||||
dispatch(fetchQueueStatus());
|
||||
}
|
||||
}, [isConnected, wasReconnecting, dispatch]);
|
||||
|
||||
return (
|
||||
<PageSidebarStatus count={count} errors={errors} warnings={warnings} />
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
|
||||
function createQueueStatusSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.queue.status.isPopulated,
|
||||
(state: AppState) => state.queue.status.item,
|
||||
(state: AppState) => state.queue.options.includeUnknownSeriesItems,
|
||||
(isPopulated, status, includeUnknownSeriesItems) => {
|
||||
const {
|
||||
errors,
|
||||
warnings,
|
||||
unknownErrors,
|
||||
unknownWarnings,
|
||||
count,
|
||||
totalCount,
|
||||
} = status;
|
||||
|
||||
return {
|
||||
...status,
|
||||
isPopulated,
|
||||
count: includeUnknownSeriesItems ? totalCount : count,
|
||||
errors: includeUnknownSeriesItems ? errors || unknownErrors : errors,
|
||||
warnings: includeUnknownSeriesItems
|
||||
? warnings || unknownWarnings
|
||||
: warnings,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default createQueueStatusSelector;
|
||||
@@ -1,54 +0,0 @@
|
||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||
import { useQueueOption } from '../queueOptionsStore';
|
||||
|
||||
export interface QueueStatus {
|
||||
totalCount: number;
|
||||
count: number;
|
||||
unknownCount: number;
|
||||
errors: boolean;
|
||||
warnings: boolean;
|
||||
unknownErrors: boolean;
|
||||
unknownWarnings: boolean;
|
||||
}
|
||||
|
||||
export default function useQueueStatus() {
|
||||
const includeUnknownSeriesItems = useQueueOption('includeUnknownSeriesItems');
|
||||
|
||||
const { data } = useApiQuery<QueueStatus>({
|
||||
path: '/queue/status',
|
||||
queryParams: {
|
||||
includeUnknownSeriesItems,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
count: 0,
|
||||
errors: false,
|
||||
warnings: false,
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
errors,
|
||||
warnings,
|
||||
unknownErrors,
|
||||
unknownWarnings,
|
||||
count,
|
||||
totalCount,
|
||||
} = data;
|
||||
|
||||
if (includeUnknownSeriesItems) {
|
||||
return {
|
||||
count: totalCount,
|
||||
errors: errors || unknownErrors,
|
||||
warnings: warnings || unknownWarnings,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
count,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
.timeLeft {
|
||||
.timeleft {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 100px;
|
||||
@@ -1,7 +1,7 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'timeLeft': string;
|
||||
'timeleft': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -8,26 +8,26 @@ import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
|
||||
import getRelativeDate from 'Utilities/Date/getRelativeDate';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './TimeLeftCell.css';
|
||||
import styles from './TimeleftCell.css';
|
||||
|
||||
interface TimeLeftCellProps {
|
||||
interface TimeleftCellProps {
|
||||
estimatedCompletionTime?: string;
|
||||
timeLeft?: string;
|
||||
timeleft?: string;
|
||||
status: string;
|
||||
size: number;
|
||||
sizeLeft: number;
|
||||
sizeleft: number;
|
||||
showRelativeDates: boolean;
|
||||
shortDateFormat: string;
|
||||
timeFormat: string;
|
||||
}
|
||||
|
||||
function TimeLeftCell(props: TimeLeftCellProps) {
|
||||
function TimeleftCell(props: TimeleftCellProps) {
|
||||
const {
|
||||
estimatedCompletionTime,
|
||||
timeLeft,
|
||||
timeleft,
|
||||
status,
|
||||
size,
|
||||
sizeLeft,
|
||||
sizeleft,
|
||||
showRelativeDates,
|
||||
shortDateFormat,
|
||||
timeFormat,
|
||||
@@ -44,7 +44,7 @@ function TimeLeftCell(props: TimeLeftCellProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<TableRowCell className={styles.timeLeft}>
|
||||
<TableRowCell className={styles.timeleft}>
|
||||
<Tooltip
|
||||
anchor={<Icon name={icons.INFO} />}
|
||||
tooltip={translate('DelayingDownloadUntil', { date, time })}
|
||||
@@ -66,7 +66,7 @@ function TimeLeftCell(props: TimeLeftCellProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<TableRowCell className={styles.timeLeft}>
|
||||
<TableRowCell className={styles.timeleft}>
|
||||
<Tooltip
|
||||
anchor={<Icon name={icons.INFO} />}
|
||||
tooltip={translate('RetryingDownloadOn', { date, time })}
|
||||
@@ -77,21 +77,21 @@ function TimeLeftCell(props: TimeLeftCellProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!timeLeft || status === 'completed' || status === 'failed') {
|
||||
return <TableRowCell className={styles.timeLeft}>-</TableRowCell>;
|
||||
if (!timeleft || status === 'completed' || status === 'failed') {
|
||||
return <TableRowCell className={styles.timeleft}>-</TableRowCell>;
|
||||
}
|
||||
|
||||
const totalSize = formatBytes(size);
|
||||
const remainingSize = formatBytes(sizeLeft);
|
||||
const remainingSize = formatBytes(sizeleft);
|
||||
|
||||
return (
|
||||
<TableRowCell
|
||||
className={styles.timeLeft}
|
||||
className={styles.timeleft}
|
||||
title={`${remainingSize} / ${totalSize}`}
|
||||
>
|
||||
{formatTimeSpan(timeLeft)}
|
||||
{formatTimeSpan(timeleft)}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimeLeftCell;
|
||||
export default TimeleftCell;
|
||||
@@ -1,164 +0,0 @@
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Column from 'Components/Table/Column';
|
||||
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
interface QueueRemovalOptions {
|
||||
removalMethod: 'changeCategory' | 'ignore' | 'removeFromClient';
|
||||
blocklistMethod: 'blocklistAndSearch' | 'blocklistOnly' | 'doNotBlocklist';
|
||||
}
|
||||
|
||||
export interface QueueOptions {
|
||||
includeUnknownSeriesItems: boolean;
|
||||
pageSize: number;
|
||||
selectedFilterKey: string | number;
|
||||
sortKey: string;
|
||||
sortDirection: SortDirection;
|
||||
columns: Column[];
|
||||
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;
|
||||
@@ -1,210 +0,0 @@
|
||||
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 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,
|
||||
},
|
||||
});
|
||||
|
||||
const handleGoToPage = useCallback(
|
||||
(page: number) => {
|
||||
goToPage(page);
|
||||
},
|
||||
[goToPage]
|
||||
);
|
||||
|
||||
return {
|
||||
...query,
|
||||
goToPage: handleGoToPage,
|
||||
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,7 +47,11 @@ function AddNewSeriesModalContent({
|
||||
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
||||
const isWindows = useIsWindows();
|
||||
|
||||
const { isAdding, addError, addSeries } = useAddSeries();
|
||||
const {
|
||||
isPending: isAdding,
|
||||
error: addError,
|
||||
mutate: addSeries,
|
||||
} = useAddSeries();
|
||||
|
||||
const { settings, validationErrors, validationWarnings } = useMemo(() => {
|
||||
return selectSettings(options, {}, addError);
|
||||
|
||||
@@ -33,19 +33,11 @@ export const useAddSeries = () => {
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const { isPending, error, mutate } = useApiMutation<Series, AddSeriesPayload>(
|
||||
{
|
||||
path: '/series',
|
||||
method: 'POST',
|
||||
mutationOptions: {
|
||||
onSuccess: onAddSuccess,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
isAdding: isPending,
|
||||
addError: error,
|
||||
addSeries: mutate,
|
||||
};
|
||||
return useApiMutation<Series, AddSeriesPayload>({
|
||||
path: '/series',
|
||||
method: 'POST',
|
||||
mutationOptions: {
|
||||
onSuccess: onAddSuccess,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
|
||||
import { createPersist } from 'Helpers/createPersist';
|
||||
import { SeriesMonitor, SeriesType } from 'Series/Series';
|
||||
|
||||
export interface AddSeriesOptions {
|
||||
@@ -12,8 +12,9 @@ export interface AddSeriesOptions {
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
const { useOptions, useOption, setOption } =
|
||||
createOptionsStore<AddSeriesOptions>('add_series_options', () => {
|
||||
const addSeriesOptionsStore = createPersist<AddSeriesOptions>(
|
||||
'add_series_options',
|
||||
() => {
|
||||
return {
|
||||
rootFolderPath: '',
|
||||
monitor: 'all',
|
||||
@@ -24,8 +25,25 @@ const { useOptions, useOption, setOption } =
|
||||
searchForCutoffUnmetEpisodes: false,
|
||||
tags: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const useAddSeriesOptions = useOptions;
|
||||
export const useAddSeriesOption = useOption;
|
||||
export const setAddSeriesOption = setOption;
|
||||
export const useAddSeriesOptions = () => {
|
||||
return addSeriesOptionsStore((state) => state);
|
||||
};
|
||||
|
||||
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,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import OrganizePreviewAppState from './OrganizePreviewAppState';
|
||||
import ParseAppState from './ParseAppState';
|
||||
import PathsAppState from './PathsAppState';
|
||||
import ProviderOptionsAppState from './ProviderOptionsAppState';
|
||||
import QueueAppState from './QueueAppState';
|
||||
import ReleasesAppState from './ReleasesAppState';
|
||||
import RootFolderAppState from './RootFolderAppState';
|
||||
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
|
||||
@@ -98,6 +99,7 @@ interface AppState {
|
||||
parse: ParseAppState;
|
||||
paths: PathsAppState;
|
||||
providerOptions: ProviderOptionsAppState;
|
||||
queue: QueueAppState;
|
||||
releases: ReleasesAppState;
|
||||
rootFolders: RootFolderAppState;
|
||||
series: SeriesAppState;
|
||||
|
||||
56
frontend/src/App/State/QueueAppState.ts
Normal file
56
frontend/src/App/State/QueueAppState.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import Queue from 'typings/Queue';
|
||||
import AppSectionState, {
|
||||
AppSectionFilterState,
|
||||
AppSectionItemState,
|
||||
Error,
|
||||
PagedAppSectionState,
|
||||
TableAppSectionState,
|
||||
} from './AppSectionState';
|
||||
|
||||
export interface QueueStatus {
|
||||
totalCount: number;
|
||||
count: number;
|
||||
unknownCount: number;
|
||||
errors: boolean;
|
||||
warnings: boolean;
|
||||
unknownErrors: boolean;
|
||||
unknownWarnings: boolean;
|
||||
}
|
||||
|
||||
export interface QueueDetailsAppState extends AppSectionState<Queue> {
|
||||
params: unknown;
|
||||
}
|
||||
|
||||
export interface QueuePagedAppState
|
||||
extends AppSectionState<Queue>,
|
||||
AppSectionFilterState<Queue>,
|
||||
PagedAppSectionState,
|
||||
TableAppSectionState {
|
||||
isGrabbing: boolean;
|
||||
grabError: Error;
|
||||
isRemoving: boolean;
|
||||
removeError: Error;
|
||||
}
|
||||
|
||||
export type RemovalMethod = 'removeFromClient' | 'changeCategory' | 'ignore';
|
||||
export type BlocklistMethod =
|
||||
| 'doNotBlocklist'
|
||||
| 'blocklistAndSearch'
|
||||
| 'blocklistOnly';
|
||||
|
||||
interface RemovalOptions {
|
||||
removalMethod: RemovalMethod;
|
||||
blocklistMethod: BlocklistMethod;
|
||||
}
|
||||
|
||||
interface QueueAppState {
|
||||
status: AppSectionItemState<QueueStatus>;
|
||||
details: QueueDetailsAppState;
|
||||
paged: QueuePagedAppState;
|
||||
options: {
|
||||
includeUnknownSeriesItems: boolean;
|
||||
};
|
||||
removalOptions: RemovalOptions;
|
||||
}
|
||||
|
||||
export default QueueAppState;
|
||||
@@ -2,7 +2,6 @@ import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import AppState from 'App/State/AppState';
|
||||
import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
|
||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||
@@ -14,6 +13,7 @@ import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
||||
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import formatTime from 'Utilities/Date/formatTime';
|
||||
import padNumber from 'Utilities/Number/padNumber';
|
||||
@@ -57,7 +57,7 @@ function AgendaEvent(props: AgendaEventProps) {
|
||||
|
||||
const series = useSeries(seriesId)!;
|
||||
const episodeFile = useEpisodeFile(episodeFileId);
|
||||
const queueItem = useQueueItemForEpisode(id);
|
||||
const queueItem = useSelector(createQueueItemSelectorForHook(id));
|
||||
const { timeFormat, longDateFormat, enableColorImpairedMode } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
clearEpisodeFiles,
|
||||
fetchEpisodeFiles,
|
||||
} from 'Store/Actions/episodeFileActions';
|
||||
import {
|
||||
clearQueueDetails,
|
||||
fetchQueueDetails,
|
||||
} from 'Store/Actions/queueActions';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
|
||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||
@@ -70,6 +74,7 @@ function Calendar() {
|
||||
|
||||
return () => {
|
||||
dispatch(clearCalendar());
|
||||
dispatch(clearQueueDetails());
|
||||
dispatch(clearEpisodeFiles());
|
||||
clearTimeout(updateTimeout.current);
|
||||
};
|
||||
@@ -85,6 +90,7 @@ function Calendar() {
|
||||
|
||||
useEffect(() => {
|
||||
const repopulate = () => {
|
||||
dispatch(fetchQueueDetails({ time, view }));
|
||||
dispatch(fetchCalendar({ time, view }));
|
||||
};
|
||||
|
||||
@@ -119,11 +125,16 @@ function Calendar() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!previousItems || hasDifferentItems(items, previousItems)) {
|
||||
const episodeIds = selectUniqueIds<Episode, number>(items, 'id');
|
||||
const episodeFileIds = selectUniqueIds<Episode, number>(
|
||||
items,
|
||||
'episodeFileId'
|
||||
);
|
||||
|
||||
if (items.length) {
|
||||
dispatch(fetchQueueDetails({ episodeIds }));
|
||||
}
|
||||
|
||||
if (episodeFileIds.length) {
|
||||
dispatch(fetchEpisodeFiles({ episodeFileIds }));
|
||||
}
|
||||
@@ -133,15 +144,18 @@ function Calendar() {
|
||||
return (
|
||||
<div className={styles.calendar}>
|
||||
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
|
||||
|
||||
{!isFetching && error ? (
|
||||
<Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert>
|
||||
) : null}
|
||||
|
||||
{!error && isPopulated && view === 'agenda' ? (
|
||||
<div className={styles.calendarContent}>
|
||||
<CalendarHeader />
|
||||
<Agenda />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!error && isPopulated && view !== 'agenda' ? (
|
||||
<div className={styles.calendarContent}>
|
||||
<CalendarHeader />
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
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,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import QueueDetails from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||
@@ -10,23 +11,24 @@ import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
||||
import Episode from 'Episode/Episode';
|
||||
import useMeasure from 'Helpers/Hooks/useMeasure';
|
||||
import { align, icons } from 'Helpers/Props';
|
||||
import NoSeries from 'Series/NoSeries';
|
||||
import {
|
||||
searchMissing,
|
||||
setCalendarDaysCount,
|
||||
setCalendarFilter,
|
||||
} from 'Store/Actions/calendarActions';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||
import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
|
||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||
import { isCommandExecuting } from 'Utilities/Command';
|
||||
import isBefore from 'Utilities/Date/isBefore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import Calendar from './Calendar';
|
||||
import CalendarFilterModal from './CalendarFilterModal';
|
||||
import CalendarMissingEpisodeSearchButton from './CalendarMissingEpisodeSearchButton';
|
||||
import CalendarLinkModal from './iCal/CalendarLinkModal';
|
||||
import Legend from './Legend/Legend';
|
||||
import CalendarOptionsModal from './Options/CalendarOptionsModal';
|
||||
@@ -34,12 +36,60 @@ import styles from './CalendarPage.css';
|
||||
|
||||
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() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { selectedFilterKey, filters, items } = useSelector(
|
||||
const { selectedFilterKey, filters } = useSelector(
|
||||
(state: AppState) => state.calendar
|
||||
);
|
||||
const missingEpisodeIds = useSelector(createMissingEpisodeIdsSelector());
|
||||
const isSearchingForMissing = useSelector(createIsSearchingSelector());
|
||||
const isRssSyncExecuting = useSelector(
|
||||
createCommandExecutingSelector(commandNames.RSS_SYNC)
|
||||
);
|
||||
@@ -77,6 +127,10 @@ function CalendarPage() {
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
const handleSearchMissingPress = useCallback(() => {
|
||||
dispatch(searchMissing({ episodeIds: missingEpisodeIds }));
|
||||
}, [missingEpisodeIds, dispatch]);
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(key: string | number) => {
|
||||
dispatch(setCalendarFilter({ selectedFilterKey: key }));
|
||||
@@ -84,10 +138,6 @@ function CalendarPage() {
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const episodeIds = useMemo(() => {
|
||||
return selectUniqueIds<Episode, number>(items, 'id');
|
||||
}, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (width === 0) {
|
||||
return;
|
||||
@@ -102,67 +152,71 @@ function CalendarPage() {
|
||||
}, [width, dispatch]);
|
||||
|
||||
return (
|
||||
<QueueDetails episodeIds={episodeIds}>
|
||||
<PageContent title={translate('Calendar')}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('ICalLink')}
|
||||
iconName={icons.CALENDAR}
|
||||
onPress={handleGetCalendarLinkPress}
|
||||
/>
|
||||
<PageContent title={translate('Calendar')}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('ICalLink')}
|
||||
iconName={icons.CALENDAR}
|
||||
onPress={handleGetCalendarLinkPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('RssSync')}
|
||||
iconName={icons.RSS}
|
||||
isSpinning={isRssSyncExecuting}
|
||||
onPress={handleRssSyncPress}
|
||||
/>
|
||||
<PageToolbarButton
|
||||
label={translate('RssSync')}
|
||||
iconName={icons.RSS}
|
||||
isSpinning={isRssSyncExecuting}
|
||||
onPress={handleRssSyncPress}
|
||||
/>
|
||||
|
||||
<CalendarMissingEpisodeSearchButton />
|
||||
</PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('SearchForMissing')}
|
||||
iconName={icons.SEARCH}
|
||||
isDisabled={!missingEpisodeIds.length}
|
||||
isSpinning={isSearchingForMissing}
|
||||
onPress={handleSearchMissingPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={icons.POSTER}
|
||||
onPress={handleOptionsPress}
|
||||
/>
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={icons.POSTER}
|
||||
onPress={handleOptionsPress}
|
||||
/>
|
||||
|
||||
<FilterMenu
|
||||
alignMenu={align.RIGHT}
|
||||
isDisabled={!hasSeries}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={customFilters}
|
||||
filterModalConnectorComponent={CalendarFilterModal}
|
||||
onFilterSelect={handleFilterSelect}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
<FilterMenu
|
||||
alignMenu={align.RIGHT}
|
||||
isDisabled={!hasSeries}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={customFilters}
|
||||
filterModalConnectorComponent={CalendarFilterModal}
|
||||
onFilterSelect={handleFilterSelect}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
|
||||
<PageContentBody
|
||||
ref={pageContentRef}
|
||||
className={styles.calendarPageBody}
|
||||
innerClassName={styles.calendarInnerPageBody}
|
||||
>
|
||||
{isMeasured ? <PageComponent totalItems={0} /> : <div />}
|
||||
{hasSeries && <Legend />}
|
||||
</PageContentBody>
|
||||
<PageContentBody
|
||||
ref={pageContentRef}
|
||||
className={styles.calendarPageBody}
|
||||
innerClassName={styles.calendarInnerPageBody}
|
||||
>
|
||||
{isMeasured ? <PageComponent totalItems={0} /> : <div />}
|
||||
{hasSeries && <Legend />}
|
||||
</PageContentBody>
|
||||
|
||||
<CalendarLinkModal
|
||||
isOpen={isCalendarLinkModalOpen}
|
||||
onModalClose={handleGetCalendarLinkModalClose}
|
||||
/>
|
||||
<CalendarLinkModal
|
||||
isOpen={isCalendarLinkModalOpen}
|
||||
onModalClose={handleGetCalendarLinkModalClose}
|
||||
/>
|
||||
|
||||
<CalendarOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={handleOptionsModalClose}
|
||||
/>
|
||||
</PageContent>
|
||||
</QueueDetails>
|
||||
<CalendarOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={handleOptionsModalClose}
|
||||
/>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import AppState from 'App/State/AppState';
|
||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||
import Icon from 'Components/Icon';
|
||||
@@ -13,6 +12,7 @@ import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
||||
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import formatTime from 'Utilities/Date/formatTime';
|
||||
import padNumber from 'Utilities/Number/padNumber';
|
||||
@@ -58,7 +58,7 @@ function CalendarEvent(props: CalendarEventProps) {
|
||||
|
||||
const series = useSeries(seriesId);
|
||||
const episodeFile = useEpisodeFile(episodeFileId);
|
||||
const queueItem = useQueueItemForEpisode(id);
|
||||
const queueItem = useSelector(createQueueItemSelectorForHook(id));
|
||||
|
||||
const { timeFormat, enableColorImpairedMode } = useSelector(
|
||||
createUISettingsSelector()
|
||||
|
||||
@@ -2,7 +2,7 @@ import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useIsDownloadingEpisodes } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||
import Icon from 'Components/Icon';
|
||||
@@ -18,6 +18,17 @@ import translate from 'Utilities/String/translate';
|
||||
import CalendarEvent from './CalendarEvent';
|
||||
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 {
|
||||
episodeIds: number[];
|
||||
seriesId: number;
|
||||
@@ -31,7 +42,7 @@ function CalendarEventGroup({
|
||||
events,
|
||||
onEventModalOpenToggle,
|
||||
}: CalendarEventGroupProps) {
|
||||
const isDownloading = useIsDownloadingEpisodes(episodeIds);
|
||||
const isDownloading = useSelector(createIsDownloadingSelector(episodeIds));
|
||||
const series = useSeries(seriesId)!;
|
||||
|
||||
const { timeFormat, enableColorImpairedMode } = useSelector(
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
interface CalendarEventQueueDetailsProps {
|
||||
title: string;
|
||||
size: number;
|
||||
sizeLeft: number;
|
||||
sizeleft: number;
|
||||
estimatedCompletionTime?: string;
|
||||
status: string;
|
||||
trackedDownloadState: QueueTrackedDownloadState;
|
||||
@@ -22,7 +22,7 @@ interface CalendarEventQueueDetailsProps {
|
||||
function CalendarEventQueueDetails({
|
||||
title,
|
||||
size,
|
||||
sizeLeft,
|
||||
sizeleft,
|
||||
estimatedCompletionTime,
|
||||
status,
|
||||
trackedDownloadState,
|
||||
@@ -30,13 +30,13 @@ function CalendarEventQueueDetails({
|
||||
statusMessages,
|
||||
errorMessage,
|
||||
}: CalendarEventQueueDetailsProps) {
|
||||
const progress = size ? 100 - (sizeLeft / size) * 100 : 0;
|
||||
const progress = size ? 100 - (sizeleft / size) * 100 : 0;
|
||||
|
||||
return (
|
||||
<QueueDetails
|
||||
title={title}
|
||||
size={size}
|
||||
sizeLeft={sizeLeft}
|
||||
sizeleft={sizeleft}
|
||||
estimatedCompletionTime={estimatedCompletionTime}
|
||||
status={status}
|
||||
trackedDownloadState={trackedDownloadState}
|
||||
|
||||
@@ -3,18 +3,19 @@ import {
|
||||
HubConnectionBuilder,
|
||||
LogLevel,
|
||||
} from '@microsoft/signalr';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Command from 'Commands/Command';
|
||||
import { setAppValue, setVersion } from 'Store/Actions/appActions';
|
||||
import { removeItem, updateItem } from 'Store/Actions/baseActions';
|
||||
import { removeItem, update, updateItem } from 'Store/Actions/baseActions';
|
||||
import {
|
||||
fetchCommands,
|
||||
finishCommand,
|
||||
updateCommand,
|
||||
} from 'Store/Actions/commandActions';
|
||||
import { fetchQueue, fetchQueueDetails } from 'Store/Actions/queueActions';
|
||||
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||
import { fetchSeries } from 'Store/Actions/seriesActions';
|
||||
import { fetchQualityDefinitions } from 'Store/Actions/settingsActions';
|
||||
@@ -32,13 +33,15 @@ interface SignalRMessage {
|
||||
resource: ModelBase;
|
||||
version: string;
|
||||
};
|
||||
version: number | undefined;
|
||||
}
|
||||
|
||||
function SignalRListener() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const isQueuePopulated = useSelector(
|
||||
(state: AppState) => state.queue.paged.isPopulated
|
||||
);
|
||||
|
||||
const connection = useRef<HubConnection | null>(null);
|
||||
|
||||
const handleStartFail = useRef((error: unknown) => {
|
||||
@@ -94,14 +97,9 @@ function SignalRListener() {
|
||||
});
|
||||
|
||||
const handleReceiveMessage = useRef((message: SignalRMessage) => {
|
||||
console.debug(
|
||||
`[signalR] received ${message.name}${
|
||||
message.version ? ` v${message.version}` : ''
|
||||
}`,
|
||||
message.body
|
||||
);
|
||||
console.debug('[signalR] received', message.name, message.body);
|
||||
|
||||
const { name, body, version = 0 } = message;
|
||||
const { name, body } = message;
|
||||
|
||||
if (name === 'calendar') {
|
||||
if (body.action === 'updated') {
|
||||
@@ -237,36 +235,20 @@ function SignalRListener() {
|
||||
}
|
||||
|
||||
if (name === 'queue') {
|
||||
if (version < 5) {
|
||||
return;
|
||||
if (isQueuePopulated) {
|
||||
dispatch(fetchQueue());
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'queue/details') {
|
||||
if (version < 5) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['/queue/details'] });
|
||||
dispatch(fetchQueueDetails());
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'queue/status') {
|
||||
if (version < 5) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusDetails = queryClient.getQueriesData({
|
||||
queryKey: ['/queue/status'],
|
||||
});
|
||||
|
||||
statusDetails.forEach(([queryKey]) => {
|
||||
queryClient.setQueryData(queryKey, () => body.resource);
|
||||
});
|
||||
|
||||
dispatch(update({ section: 'queue.status', data: body.resource }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ function RelativeDateCell(props: RelativeDateCellProps) {
|
||||
date,
|
||||
includeSeconds = false,
|
||||
includeTime = false,
|
||||
|
||||
component: Component = TableRowCell,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import { useSelector } from 'react-redux';
|
||||
import QueueDetails from 'Activity/Queue/QueueDetails';
|
||||
import Icon from 'Components/Icon';
|
||||
import ProgressBar from 'Components/ProgressBar';
|
||||
@@ -7,6 +7,7 @@ import Episode from 'Episode/Episode';
|
||||
import useEpisode, { EpisodeEntity } from 'Episode/useEpisode';
|
||||
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
|
||||
import isBefore from 'Utilities/Date/isBefore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EpisodeQuality from './EpisodeQuality';
|
||||
@@ -29,7 +30,7 @@ function EpisodeStatus({
|
||||
grabbed = false,
|
||||
} = useEpisode(episodeId, episodeEntity) as Episode;
|
||||
|
||||
const queueItem = useQueueItemForEpisode(episodeId);
|
||||
const queueItem = useSelector(createQueueItemSelectorForHook(episodeId));
|
||||
const episodeFile = useEpisodeFile(episodeFileId);
|
||||
|
||||
const hasEpisodeFile = !!episodeFile;
|
||||
@@ -37,9 +38,9 @@ function EpisodeStatus({
|
||||
const hasAired = isBefore(airDateUtc);
|
||||
|
||||
if (isQueued) {
|
||||
const { sizeLeft, size } = queueItem;
|
||||
const { sizeleft, size } = queueItem;
|
||||
|
||||
const progress = size ? 100 - (sizeLeft / size) * 100 : 0;
|
||||
const progress = size ? 100 - (sizeleft / size) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className={styles.center}>
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
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));
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { Error } from 'App/State/AppSectionState';
|
||||
import fetchJson, { FetchJsonOptions } from 'Utilities/Fetch/fetchJson';
|
||||
import getQueryPath from 'Utilities/Fetch/getQueryPath';
|
||||
import getQueryString, { QueryParams } from 'Utilities/Fetch/getQueryString';
|
||||
import fetchJson, {
|
||||
apiRoot,
|
||||
FetchJsonOptions,
|
||||
} from 'Utilities/Fetch/fetchJson';
|
||||
|
||||
interface MutationOptions<T, TData>
|
||||
extends Omit<FetchJsonOptions<TData>, 'method'> {
|
||||
method: 'POST' | 'PUT' | 'DELETE';
|
||||
mutationOptions?: Omit<UseMutationOptions<T, Error, TData>, 'mutationFn'>;
|
||||
queryParams?: QueryParams;
|
||||
}
|
||||
|
||||
function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
|
||||
const requestOptions = useMemo(() => {
|
||||
return {
|
||||
...options,
|
||||
path: getQueryPath(options.path) + getQueryString(options.queryParams),
|
||||
path: apiRoot + options.path,
|
||||
headers: {
|
||||
...options.headers,
|
||||
'X-Api-Key': window.Sonarr.apiKey,
|
||||
@@ -26,11 +26,8 @@ function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
|
||||
|
||||
return useMutation<T, Error, TData>({
|
||||
...options.mutationOptions,
|
||||
mutationFn: async (data?: TData) => {
|
||||
const { path, ...otherOptions } = requestOptions;
|
||||
|
||||
return fetchJson<T, TData>({ path, ...otherOptions, body: data });
|
||||
},
|
||||
mutationFn: async (data: TData) =>
|
||||
fetchJson<T, TData>({ ...requestOptions, body: data }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,25 +15,22 @@ export interface QueryOptions<T> extends FetchJsonOptions<unknown> {
|
||||
}
|
||||
|
||||
const useApiQuery = <T>(options: QueryOptions<T>) => {
|
||||
const { queryKey, requestOptions } = useMemo(() => {
|
||||
const requestOptions = useMemo(() => {
|
||||
const { path: path, queryOptions, queryParams, ...otherOptions } = options;
|
||||
|
||||
return {
|
||||
queryKey: [path, queryParams],
|
||||
requestOptions: {
|
||||
...otherOptions,
|
||||
path: getQueryPath(path) + getQueryString(queryParams),
|
||||
headers: {
|
||||
...options.headers,
|
||||
'X-Api-Key': window.Sonarr.apiKey,
|
||||
},
|
||||
...otherOptions,
|
||||
path: getQueryPath(path) + getQueryString(queryParams),
|
||||
headers: {
|
||||
...options.headers,
|
||||
'X-Api-Key': window.Sonarr.apiKey,
|
||||
},
|
||||
};
|
||||
}, [options]);
|
||||
|
||||
return useQuery({
|
||||
...options.queryOptions,
|
||||
queryKey,
|
||||
queryKey: [requestOptions.path],
|
||||
queryFn: async ({ signal }) =>
|
||||
fetchJson<T, unknown>({ ...requestOptions, signal }),
|
||||
});
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { StateCreator } from 'zustand';
|
||||
import { PersistOptions } from 'zustand/middleware';
|
||||
import Column from 'Components/Table/Column';
|
||||
import { createPersist } from 'Helpers/createPersist';
|
||||
|
||||
type TSettingsWithoutColumns = object;
|
||||
|
||||
interface TSettingsWithColumns {
|
||||
columns: Column[];
|
||||
}
|
||||
|
||||
type TSettingd = TSettingsWithoutColumns | TSettingsWithColumns;
|
||||
|
||||
export type OptionChanged<T> = {
|
||||
name: keyof T;
|
||||
value: T[keyof T];
|
||||
};
|
||||
|
||||
export const createOptionsStore = <T extends TSettingd>(
|
||||
name: string,
|
||||
state: StateCreator<T>,
|
||||
options: Omit<PersistOptions<T>, 'name' | 'storage'> = {}
|
||||
) => {
|
||||
const store = createPersist<T>(name, state, {
|
||||
merge,
|
||||
...options,
|
||||
});
|
||||
|
||||
const useOptions = () => {
|
||||
return store((state) => state);
|
||||
};
|
||||
|
||||
const useOption = <K extends keyof T>(key: K) => {
|
||||
return store((state) => state[key]);
|
||||
};
|
||||
|
||||
const setOptions = (options: Partial<T>) => {
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
...options,
|
||||
}));
|
||||
};
|
||||
|
||||
const setOption = <K extends keyof T>(key: K, value: T[K]) => {
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
store,
|
||||
useOptions,
|
||||
useOption,
|
||||
setOptions,
|
||||
setOption,
|
||||
};
|
||||
};
|
||||
|
||||
const merge = <T extends TSettingd>(
|
||||
persistedState: unknown,
|
||||
currentState: T
|
||||
) => {
|
||||
if ('columns' in currentState) {
|
||||
return {
|
||||
...currentState,
|
||||
...mergeColumns(persistedState, currentState),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
...((persistedState as T) ?? {}),
|
||||
};
|
||||
};
|
||||
|
||||
const mergeColumns = <T extends { columns: Column[] }>(
|
||||
persistedState: unknown,
|
||||
currentState: T
|
||||
) => {
|
||||
const currentColumns = currentState.columns;
|
||||
const persistedColumns = (persistedState as T).columns;
|
||||
const columns: Column[] = [];
|
||||
|
||||
// Add persisted columns in the same order they're currently in
|
||||
// as long as they haven't been removed.
|
||||
|
||||
persistedColumns.forEach((persistedColumn) => {
|
||||
const column = currentColumns.find((i) => i.name === persistedColumn.name);
|
||||
|
||||
if (column) {
|
||||
const newColumn: Partial<Column> = {};
|
||||
|
||||
// We can't use a spread operator or Object.assign to clone the column
|
||||
// or any accessors are lost and can break translations.
|
||||
for (const prop of Object.keys(column)) {
|
||||
const attributes = Object.getOwnPropertyDescriptor(column, prop);
|
||||
|
||||
if (!attributes) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.defineProperty(newColumn, prop, attributes);
|
||||
}
|
||||
|
||||
newColumn.isVisible = persistedColumn.isVisible;
|
||||
|
||||
columns.push(newColumn as Column);
|
||||
}
|
||||
});
|
||||
|
||||
// Add any columns added to the app in the initial position.
|
||||
currentColumns.forEach((currentColumn, index) => {
|
||||
const persistedColumnIndex = persistedColumns.findIndex(
|
||||
(i) => i.name === currentColumn.name
|
||||
);
|
||||
const column = Object.assign({}, currentColumn);
|
||||
|
||||
if (persistedColumnIndex === -1) {
|
||||
columns.splice(index, 0, column);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...(persistedState as T),
|
||||
columns,
|
||||
};
|
||||
};
|
||||
@@ -4,12 +4,10 @@ import { create } from 'zustand';
|
||||
|
||||
interface PageStore {
|
||||
events: number;
|
||||
queue: number;
|
||||
}
|
||||
|
||||
const pageStore = create<PageStore>(() => ({
|
||||
events: 1,
|
||||
queue: 1,
|
||||
}));
|
||||
|
||||
const usePage = (kind: keyof PageStore) => {
|
||||
|
||||
@@ -26,7 +26,7 @@ interface PagedQueryResponse<T> {
|
||||
}
|
||||
|
||||
const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
|
||||
const { requestOptions, queryKey } = useMemo(() => {
|
||||
const requestOptions = useMemo(() => {
|
||||
const {
|
||||
path,
|
||||
page,
|
||||
@@ -40,38 +40,27 @@ const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
|
||||
} = options;
|
||||
|
||||
return {
|
||||
queryKey: [
|
||||
path,
|
||||
queryParams,
|
||||
page,
|
||||
pageSize,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
filters,
|
||||
],
|
||||
requestOptions: {
|
||||
...otherOptions,
|
||||
path:
|
||||
getQueryPath(path) +
|
||||
getQueryString({
|
||||
...queryParams,
|
||||
page,
|
||||
pageSize,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
filters,
|
||||
}),
|
||||
headers: {
|
||||
...options.headers,
|
||||
'X-Api-Key': window.Sonarr.apiKey,
|
||||
},
|
||||
...otherOptions,
|
||||
path:
|
||||
getQueryPath(path) +
|
||||
getQueryString({
|
||||
...queryParams,
|
||||
page,
|
||||
pageSize,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
filters,
|
||||
}),
|
||||
headers: {
|
||||
...options.headers,
|
||||
'X-Api-Key': window.Sonarr.apiKey,
|
||||
},
|
||||
};
|
||||
}, [options]);
|
||||
|
||||
return useQuery({
|
||||
...options.queryOptions,
|
||||
queryKey,
|
||||
queryKey: [requestOptions.path],
|
||||
queryFn: async ({ signal }) => {
|
||||
const response = await fetchJson<PagedQueryResponse<T>, unknown>({
|
||||
...requestOptions,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create, type StateCreator } from 'zustand';
|
||||
import { persist, type PersistOptions } from 'zustand/middleware';
|
||||
import Column from 'Components/Table/Column';
|
||||
|
||||
export const createPersist = <T>(
|
||||
name: string,
|
||||
@@ -18,3 +19,56 @@ export const createPersist = <T>(
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const mergeColumns = <T extends { columns: Column[] }>(
|
||||
persistedState: unknown,
|
||||
currentState: T
|
||||
) => {
|
||||
const currentColumns = currentState.columns;
|
||||
const persistedColumns = (persistedState as T).columns;
|
||||
const columns: Column[] = [];
|
||||
|
||||
// Add persisted columns in the same order they're currently in
|
||||
// as long as they haven't been removed.
|
||||
|
||||
persistedColumns.forEach((persistedColumn) => {
|
||||
const column = currentColumns.find((i) => i.name === persistedColumn.name);
|
||||
|
||||
if (column) {
|
||||
const newColumn: Partial<Column> = {};
|
||||
|
||||
// We can't use a spread operator or Object.assign to clone the column
|
||||
// or any accessors are lost and can break translations.
|
||||
for (const prop of Object.keys(column)) {
|
||||
const attributes = Object.getOwnPropertyDescriptor(column, prop);
|
||||
|
||||
if (!attributes) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.defineProperty(newColumn, prop, attributes);
|
||||
}
|
||||
|
||||
newColumn.isVisible = persistedColumn.isVisible;
|
||||
|
||||
columns.push(newColumn as Column);
|
||||
}
|
||||
});
|
||||
|
||||
// Add any columns added to the app in the initial position.
|
||||
currentColumns.forEach((currentColumn, index) => {
|
||||
const persistedColumnIndex = persistedColumns.findIndex(
|
||||
(i) => i.name === currentColumn.name
|
||||
);
|
||||
const column = Object.assign({}, currentColumn);
|
||||
|
||||
if (persistedColumnIndex === -1) {
|
||||
columns.splice(index, 0, column);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...(persistedState as T),
|
||||
columns,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React from 'react';
|
||||
import { useQueueDetailsForSeries } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import createSeriesQueueItemsDetailsSelector, {
|
||||
SeriesQueueDetails,
|
||||
} from 'Series/Index/createSeriesQueueDetailsSelector';
|
||||
|
||||
function getEpisodeCountKind(
|
||||
monitored: boolean,
|
||||
@@ -41,7 +44,9 @@ function SeasonProgressLabel({
|
||||
episodeCount,
|
||||
episodeFileCount,
|
||||
}: SeasonProgressLabelProps) {
|
||||
const queueDetails = useQueueDetailsForSeries(seriesId, seasonNumber);
|
||||
const queueDetails: SeriesQueueDetails = useSelector(
|
||||
createSeriesQueueItemsDetailsSelector(seriesId, seasonNumber)
|
||||
);
|
||||
|
||||
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
|
||||
const text = newDownloads
|
||||
|
||||
@@ -2,7 +2,6 @@ import moment from 'moment';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import Alert from 'Components/Alert';
|
||||
@@ -48,6 +47,10 @@ import {
|
||||
clearEpisodeFiles,
|
||||
fetchEpisodeFiles,
|
||||
} from 'Store/Actions/episodeFileActions';
|
||||
import {
|
||||
clearQueueDetails,
|
||||
fetchQueueDetails,
|
||||
} from 'Store/Actions/queueActions';
|
||||
import { toggleSeriesMonitored } from 'Store/Actions/seriesActions';
|
||||
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||
@@ -377,6 +380,7 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
|
||||
const populate = useCallback(() => {
|
||||
dispatch(fetchEpisodes({ seriesId }));
|
||||
dispatch(fetchEpisodeFiles({ seriesId }));
|
||||
dispatch(fetchQueueDetails({ seriesId }));
|
||||
}, [seriesId, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -390,6 +394,7 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
|
||||
unregisterPagePopulator(populate);
|
||||
dispatch(clearEpisodes());
|
||||
dispatch(clearEpisodeFiles());
|
||||
dispatch(clearQueueDetails());
|
||||
};
|
||||
}, [populate, dispatch]);
|
||||
|
||||
@@ -461,435 +466,424 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
|
||||
const isPopulated = isEpisodesPopulated && isEpisodeFilesPopulated;
|
||||
|
||||
return (
|
||||
<QueueDetailsProvider seriesId={seriesId}>
|
||||
<PageContent title={title}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('RefreshAndScan')}
|
||||
iconName={icons.REFRESH}
|
||||
spinningName={icons.REFRESH}
|
||||
title={translate('RefreshAndScanTooltip')}
|
||||
isSpinning={isRefreshing}
|
||||
onPress={handleRefreshPress}
|
||||
<PageContent title={title}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('RefreshAndScan')}
|
||||
iconName={icons.REFRESH}
|
||||
spinningName={icons.REFRESH}
|
||||
title={translate('RefreshAndScanTooltip')}
|
||||
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}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('SearchMonitored')}
|
||||
iconName={icons.SEARCH}
|
||||
isDisabled={!monitored || !hasMonitoredEpisodes || !hasEpisodes}
|
||||
isSpinning={isSearching}
|
||||
title={
|
||||
hasMonitoredEpisodes
|
||||
? undefined
|
||||
: translate('NoMonitoredEpisodes')
|
||||
}
|
||||
onPress={handleSearchPress}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
<div className={styles.title}>{title}</div>
|
||||
|
||||
<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}>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.titleContainer}>
|
||||
<div className={styles.toggleMonitoredContainer}>
|
||||
<MonitorToggleButton
|
||||
className={styles.monitorToggleButton}
|
||||
monitored={monitored}
|
||||
isSaving={isSaving}
|
||||
size={40}
|
||||
onPress={handleMonitorTogglePress}
|
||||
{alternateTitles.length ? (
|
||||
<div className={styles.alternateTitlesIconContainer}>
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon name={icons.ALTERNATE_TITLES} size={20} />
|
||||
}
|
||||
title={translate('AlternateTitles')}
|
||||
body={
|
||||
<SeriesAlternateTitles
|
||||
alternateTitles={alternateTitles}
|
||||
/>
|
||||
}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.title}>{title}</div>
|
||||
|
||||
{alternateTitles.length ? (
|
||||
<div className={styles.alternateTitlesIconContainer}>
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon name={icons.ALTERNATE_TITLES} size={20} />
|
||||
}
|
||||
title={translate('AlternateTitles')}
|
||||
body={
|
||||
<SeriesAlternateTitles
|
||||
alternateTitles={alternateTitles}
|
||||
/>
|
||||
}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.seriesNavigationButtons}>
|
||||
{previousSeries ? (
|
||||
<IconButton
|
||||
className={styles.seriesNavigationButton}
|
||||
name={icons.ARROW_LEFT}
|
||||
size={30}
|
||||
title={translate('SeriesDetailsGoTo', {
|
||||
title: previousSeries.title,
|
||||
})}
|
||||
to={`/series/${previousSeries.titleSlug}`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{nextSeries ? (
|
||||
<IconButton
|
||||
className={styles.seriesNavigationButton}
|
||||
name={icons.ARROW_RIGHT}
|
||||
size={30}
|
||||
title={translate('SeriesDetailsGoTo', {
|
||||
title: nextSeries.title,
|
||||
})}
|
||||
to={`/series/${nextSeries.titleSlug}`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</Label>
|
||||
}
|
||||
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>
|
||||
</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}
|
||||
<div className={styles.seriesNavigationButtons}>
|
||||
{previousSeries ? (
|
||||
<IconButton
|
||||
className={styles.seriesNavigationButton}
|
||||
name={icons.ARROW_LEFT}
|
||||
size={30}
|
||||
title={translate('SeriesDetailsGoTo', {
|
||||
title: previousSeries.title,
|
||||
})}
|
||||
to={`/series/${previousSeries.titleSlug}`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SeriesProgressLabel
|
||||
className={styles.seriesProgressLabel}
|
||||
seriesId={seriesId}
|
||||
monitored={monitored}
|
||||
episodeCount={episodeCount}
|
||||
episodeFileCount={episodeFileCount}
|
||||
/>
|
||||
{nextSeries ? (
|
||||
<IconButton
|
||||
className={styles.seriesNavigationButton}
|
||||
name={icons.ARROW_RIGHT}
|
||||
size={30}
|
||||
title={translate('SeriesDetailsGoTo', {
|
||||
title: nextSeries.title,
|
||||
})}
|
||||
to={`/series/${nextSeries.titleSlug}`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.overview}>{overview}</div>
|
||||
|
||||
<MetadataAttribution />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</Label>
|
||||
}
|
||||
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>
|
||||
</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>
|
||||
|
||||
<div className={styles.contentContainer}>
|
||||
{!isPopulated && !episodesError && !episodeFilesError ? (
|
||||
<LoadingIndicator />
|
||||
) : null}
|
||||
<div className={styles.contentContainer}>
|
||||
{!isPopulated && !episodesError && !episodeFilesError ? (
|
||||
<LoadingIndicator />
|
||||
) : null}
|
||||
|
||||
{!isFetching && episodesError ? (
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('EpisodesLoadError')}
|
||||
</Alert>
|
||||
) : null}
|
||||
{!isFetching && episodesError ? (
|
||||
<Alert kind={kinds.DANGER}>{translate('EpisodesLoadError')}</Alert>
|
||||
) : null}
|
||||
|
||||
{!isFetching && episodeFilesError ? (
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('EpisodeFilesLoadError')}
|
||||
</Alert>
|
||||
) : null}
|
||||
{!isFetching && episodeFilesError ? (
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('EpisodeFilesLoadError')}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{isPopulated && !!seasons.length ? (
|
||||
<div>
|
||||
{seasons
|
||||
.slice(0)
|
||||
.reverse()
|
||||
.map((season) => {
|
||||
return (
|
||||
<SeriesDetailsSeason
|
||||
key={season.seasonNumber}
|
||||
seriesId={seriesId}
|
||||
{...season}
|
||||
isExpanded={expandedState.seasons[season.seasonNumber]}
|
||||
onExpandPress={handleExpandPress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{isPopulated && !!seasons.length ? (
|
||||
<div>
|
||||
{seasons
|
||||
.slice(0)
|
||||
.reverse()
|
||||
.map((season) => {
|
||||
return (
|
||||
<SeriesDetailsSeason
|
||||
key={season.seasonNumber}
|
||||
seriesId={seriesId}
|
||||
{...season}
|
||||
isExpanded={expandedState.seasons[season.seasonNumber]}
|
||||
onExpandPress={handleExpandPress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isPopulated && !seasons.length ? (
|
||||
<Alert kind={kinds.WARNING}>
|
||||
{translate('NoEpisodeInformation')}
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
{isPopulated && !seasons.length ? (
|
||||
<Alert kind={kinds.WARNING}>
|
||||
{translate('NoEpisodeInformation')}
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<OrganizePreviewModal
|
||||
isOpen={isOrganizeModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleOrganizeModalClose}
|
||||
/>
|
||||
<OrganizePreviewModal
|
||||
isOpen={isOrganizeModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleOrganizeModalClose}
|
||||
/>
|
||||
|
||||
<InteractiveImportModal
|
||||
isOpen={isManageEpisodesOpen}
|
||||
seriesId={seriesId}
|
||||
title={title}
|
||||
folder={path}
|
||||
initialSortKey="relativePath"
|
||||
initialSortDirection={sortDirections.DESCENDING}
|
||||
showSeries={false}
|
||||
allowSeriesChange={false}
|
||||
showDelete={true}
|
||||
showImportMode={false}
|
||||
modalTitle={translate('ManageEpisodes')}
|
||||
onModalClose={handleManageEpisodesModalClose}
|
||||
/>
|
||||
<InteractiveImportModal
|
||||
isOpen={isManageEpisodesOpen}
|
||||
seriesId={seriesId}
|
||||
title={title}
|
||||
folder={path}
|
||||
initialSortKey="relativePath"
|
||||
initialSortDirection={sortDirections.DESCENDING}
|
||||
showSeries={false}
|
||||
allowSeriesChange={false}
|
||||
showDelete={true}
|
||||
showImportMode={false}
|
||||
modalTitle={translate('ManageEpisodes')}
|
||||
onModalClose={handleManageEpisodesModalClose}
|
||||
/>
|
||||
|
||||
<SeriesHistoryModal
|
||||
isOpen={isSeriesHistoryModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleSeriesHistoryModalClose}
|
||||
/>
|
||||
<SeriesHistoryModal
|
||||
isOpen={isSeriesHistoryModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleSeriesHistoryModalClose}
|
||||
/>
|
||||
|
||||
<EditSeriesModal
|
||||
isOpen={isEditSeriesModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleEditSeriesModalClose}
|
||||
onDeleteSeriesPress={handleDeleteSeriesPress}
|
||||
/>
|
||||
<EditSeriesModal
|
||||
isOpen={isEditSeriesModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleEditSeriesModalClose}
|
||||
onDeleteSeriesPress={handleDeleteSeriesPress}
|
||||
/>
|
||||
|
||||
<DeleteSeriesModal
|
||||
isOpen={isDeleteSeriesModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleDeleteSeriesModalClose}
|
||||
/>
|
||||
<DeleteSeriesModal
|
||||
isOpen={isDeleteSeriesModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleDeleteSeriesModalClose}
|
||||
/>
|
||||
|
||||
<MonitoringOptionsModal
|
||||
isOpen={isMonitorOptionsModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleMonitorOptionsClose}
|
||||
/>
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
</QueueDetailsProvider>
|
||||
<MonitoringOptionsModal
|
||||
isOpen={isMonitorOptionsModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleMonitorOptionsClose}
|
||||
/>
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React from 'react';
|
||||
import { useQueueDetailsForSeries } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import createSeriesQueueItemsDetailsSelector, {
|
||||
SeriesQueueDetails,
|
||||
} from 'Series/Index/createSeriesQueueDetailsSelector';
|
||||
|
||||
function getEpisodeCountKind(
|
||||
monitored: boolean,
|
||||
@@ -39,7 +42,9 @@ function SeriesProgressLabel({
|
||||
episodeCount,
|
||||
episodeFileCount,
|
||||
}: SeriesProgressLabelProps) {
|
||||
const queueDetails = useQueueDetailsForSeries(seriesId);
|
||||
const queueDetails: SeriesQueueDetails = useSelector(
|
||||
createSeriesQueueItemsDetailsSelector(seriesId)
|
||||
);
|
||||
|
||||
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
|
||||
const text = newDownloads
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React from 'react';
|
||||
import { useQueueDetailsForSeries } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import { useSelector } from 'react-redux';
|
||||
import ProgressBar from 'Components/ProgressBar';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import createSeriesQueueItemsDetailsSelector, {
|
||||
SeriesQueueDetails,
|
||||
} from 'Series/Index/createSeriesQueueDetailsSelector';
|
||||
import { SeriesStatus } from 'Series/Series';
|
||||
import getProgressBarKind from 'Utilities/Series/getProgressBarKind';
|
||||
import translate from 'Utilities/String/translate';
|
||||
@@ -34,7 +37,9 @@ function SeriesIndexProgressBar(props: SeriesIndexProgressBarProps) {
|
||||
isStandalone,
|
||||
} = props;
|
||||
|
||||
const queueDetails = useQueueDetailsForSeries(seriesId, seasonNumber);
|
||||
const queueDetails: SeriesQueueDetails = useSelector(
|
||||
createSeriesQueueItemsDetailsSelector(seriesId, seasonNumber)
|
||||
);
|
||||
|
||||
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
|
||||
const progress = episodeCount ? (episodeFileCount / episodeCount) * 100 : 100;
|
||||
|
||||
@@ -6,7 +6,6 @@ import React, {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import { SelectProvider } from 'App/SelectContext';
|
||||
import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState';
|
||||
import SeriesAppState, { SeriesIndexAppState } from 'App/State/SeriesAppState';
|
||||
@@ -27,6 +26,7 @@ import { DESCENDING } from 'Helpers/Props/sortDirections';
|
||||
import ParseToolbarButton from 'Parse/ParseToolbarButton';
|
||||
import NoSeries from 'Series/NoSeries';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { fetchQueueDetails } from 'Store/Actions/queueActions';
|
||||
import { fetchSeries } from 'Store/Actions/seriesActions';
|
||||
import {
|
||||
setSeriesFilter,
|
||||
@@ -104,6 +104,7 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchSeries());
|
||||
dispatch(fetchQueueDetails({ all: true }));
|
||||
}, [dispatch]);
|
||||
|
||||
const onRssSyncPress = useCallback(() => {
|
||||
@@ -216,159 +217,155 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
|
||||
const hasNoSeries = !totalItems;
|
||||
|
||||
return (
|
||||
<QueueDetailsProvider all={true}>
|
||||
<SelectProvider items={items}>
|
||||
<PageContent>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<SeriesIndexRefreshSeriesButton
|
||||
isSelectMode={isSelectMode}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
/>
|
||||
<SelectProvider items={items}>
|
||||
<PageContent>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<SeriesIndexRefreshSeriesButton
|
||||
isSelectMode={isSelectMode}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('RssSync')}
|
||||
iconName={icons.RSS}
|
||||
isSpinning={isRssSyncExecuting}
|
||||
isDisabled={hasNoSeries}
|
||||
onPress={onRssSyncPress}
|
||||
/>
|
||||
<PageToolbarButton
|
||||
label={translate('RssSync')}
|
||||
iconName={icons.RSS}
|
||||
isSpinning={isRssSyncExecuting}
|
||||
isDisabled={hasNoSeries}
|
||||
onPress={onRssSyncPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<SeriesIndexSelectModeButton
|
||||
label={
|
||||
isSelectMode
|
||||
? translate('StopSelecting')
|
||||
: translate('SelectSeries')
|
||||
}
|
||||
iconName={isSelectMode ? icons.SERIES_ENDED : icons.CHECK}
|
||||
isSelectMode={isSelectMode}
|
||||
overflowComponent={SeriesIndexSelectModeMenuItem}
|
||||
onPress={onSelectModePress}
|
||||
/>
|
||||
<SeriesIndexSelectModeButton
|
||||
label={
|
||||
isSelectMode
|
||||
? translate('StopSelecting')
|
||||
: translate('SelectSeries')
|
||||
}
|
||||
iconName={isSelectMode ? icons.SERIES_ENDED : icons.CHECK}
|
||||
isSelectMode={isSelectMode}
|
||||
overflowComponent={SeriesIndexSelectModeMenuItem}
|
||||
onPress={onSelectModePress}
|
||||
/>
|
||||
|
||||
<SeriesIndexSelectAllButton
|
||||
label="SelectAll"
|
||||
isSelectMode={isSelectMode}
|
||||
overflowComponent={SeriesIndexSelectAllMenuItem}
|
||||
/>
|
||||
<SeriesIndexSelectAllButton
|
||||
label="SelectAll"
|
||||
isSelectMode={isSelectMode}
|
||||
overflowComponent={SeriesIndexSelectAllMenuItem}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
<ParseToolbarButton />
|
||||
</PageToolbarSection>
|
||||
<PageToolbarSeparator />
|
||||
<ParseToolbarButton />
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection
|
||||
alignContent={align.RIGHT}
|
||||
collapseButtons={false}
|
||||
>
|
||||
{view === 'table' ? (
|
||||
<TableOptionsModalWrapper
|
||||
columns={columns}
|
||||
optionsComponent={SeriesIndexTableOptions}
|
||||
onTableOptionChange={onTableOptionChange}
|
||||
>
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={icons.TABLE}
|
||||
/>
|
||||
</TableOptionsModalWrapper>
|
||||
) : (
|
||||
<PageToolbarSection
|
||||
alignContent={align.RIGHT}
|
||||
collapseButtons={false}
|
||||
>
|
||||
{view === 'table' ? (
|
||||
<TableOptionsModalWrapper
|
||||
columns={columns}
|
||||
optionsComponent={SeriesIndexTableOptions}
|
||||
onTableOptionChange={onTableOptionChange}
|
||||
>
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW}
|
||||
isDisabled={hasNoSeries}
|
||||
onPress={onOptionsPress}
|
||||
iconName={icons.TABLE}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<SeriesIndexViewMenu
|
||||
view={view}
|
||||
</TableOptionsModalWrapper>
|
||||
) : (
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW}
|
||||
isDisabled={hasNoSeries}
|
||||
onViewSelect={onViewSelect}
|
||||
onPress={onOptionsPress}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SeriesIndexSortMenu
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
isDisabled={hasNoSeries}
|
||||
onSortSelect={onSortSelect}
|
||||
/>
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<SeriesIndexFilterMenu
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
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}
|
||||
<SeriesIndexViewMenu
|
||||
view={view}
|
||||
isDisabled={hasNoSeries}
|
||||
onViewSelect={onViewSelect}
|
||||
/>
|
||||
|
||||
{!isFetching && !!error ? (
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('SeriesLoadError')}
|
||||
</Alert>
|
||||
) : null}
|
||||
<SeriesIndexSortMenu
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
isDisabled={hasNoSeries}
|
||||
onSortSelect={onSortSelect}
|
||||
/>
|
||||
|
||||
{isLoaded ? (
|
||||
<div className={styles.contentBodyContainer}>
|
||||
<ViewComponent
|
||||
scrollerRef={scrollerRef}
|
||||
items={items}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
jumpToCharacter={jumpToCharacter}
|
||||
isSelectMode={isSelectMode}
|
||||
isSmallScreen={isSmallScreen}
|
||||
/>
|
||||
<SeriesIndexFilterMenu
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
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}
|
||||
|
||||
<SeriesIndexFooter />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!error && isPopulated && !items.length ? (
|
||||
<NoSeries totalItems={totalItems} />
|
||||
) : null}
|
||||
</PageContentBody>
|
||||
{isLoaded && !!jumpBarItems.order.length ? (
|
||||
<PageJumpBar
|
||||
items={jumpBarItems}
|
||||
onItemPress={onJumpBarItemPress}
|
||||
/>
|
||||
{!isFetching && !!error ? (
|
||||
<Alert kind={kinds.DANGER}>{translate('SeriesLoadError')}</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isSelectMode ? <SeriesIndexSelectFooter /> : null}
|
||||
{isLoaded ? (
|
||||
<div className={styles.contentBodyContainer}>
|
||||
<ViewComponent
|
||||
scrollerRef={scrollerRef}
|
||||
items={items}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
jumpToCharacter={jumpToCharacter}
|
||||
isSelectMode={isSelectMode}
|
||||
isSmallScreen={isSmallScreen}
|
||||
/>
|
||||
|
||||
{view === 'posters' ? (
|
||||
<SeriesIndexPosterOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={onOptionsModalClose}
|
||||
<SeriesIndexFooter />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!error && isPopulated && !items.length ? (
|
||||
<NoSeries totalItems={totalItems} />
|
||||
) : null}
|
||||
</PageContentBody>
|
||||
{isLoaded && !!jumpBarItems.order.length ? (
|
||||
<PageJumpBar
|
||||
items={jumpBarItems}
|
||||
onItemPress={onJumpBarItemPress}
|
||||
/>
|
||||
) : null}
|
||||
{view === 'overview' ? (
|
||||
<SeriesIndexOverviewOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={onOptionsModalClose}
|
||||
/>
|
||||
) : null}
|
||||
</PageContent>
|
||||
</SelectProvider>
|
||||
</QueueDetailsProvider>
|
||||
</div>
|
||||
|
||||
{isSelectMode ? <SeriesIndexSelectFooter /> : null}
|
||||
|
||||
{view === 'posters' ? (
|
||||
<SeriesIndexPosterOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={onOptionsModalClose}
|
||||
/>
|
||||
) : null}
|
||||
{view === 'overview' ? (
|
||||
<SeriesIndexOverviewOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={onOptionsModalClose}
|
||||
/>
|
||||
) : null}
|
||||
</PageContent>
|
||||
</SelectProvider>
|
||||
);
|
||||
}, 'seriesIndex');
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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;
|
||||
@@ -171,7 +171,7 @@ function HostSettings({
|
||||
</FormGroup>
|
||||
) : null}
|
||||
|
||||
{isWindowsService ? null : (
|
||||
{isWindowsService ? (
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>{translate('OpenBrowserOnStart')}</FormLabel>
|
||||
|
||||
@@ -183,7 +183,7 @@ function HostSettings({
|
||||
{...launchBrowser}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
) : null}
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import * as organizePreview from './organizePreviewActions';
|
||||
import * as parse from './parseActions';
|
||||
import * as paths from './pathActions';
|
||||
import * as providerOptions from './providerOptionActions';
|
||||
import * as queue from './queueActions';
|
||||
import * as releases from './releaseActions';
|
||||
import * as rootFolders from './rootFolderActions';
|
||||
import * as series from './seriesActions';
|
||||
@@ -45,6 +46,7 @@ export default [
|
||||
parse,
|
||||
paths,
|
||||
providerOptions,
|
||||
queue,
|
||||
releases,
|
||||
rootFolders,
|
||||
series,
|
||||
|
||||
562
frontend/src/Store/Actions/queueActions.js
Normal file
562
frontend/src/Store/Actions/queueActions.js
Normal file
@@ -0,0 +1,562 @@
|
||||
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);
|
||||
|
||||
31
frontend/src/Store/Selectors/createQueueItemSelector.ts
Normal file
31
frontend/src/Store/Selectors/createQueueItemSelector.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
|
||||
export function createQueueItemSelectorForHook(episodeId: number) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.queue.details.items,
|
||||
(details) => {
|
||||
if (!episodeId || !details) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return details.find((item) => item.episodeId === episodeId);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createQueueItemSelector() {
|
||||
return createSelector(
|
||||
(_: AppState, { episodeId }: { episodeId: number }) => episodeId,
|
||||
(state: AppState) => state.queue.details.items,
|
||||
(episodeId, details) => {
|
||||
if (!episodeId || !details) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return details.find((item) => item.episodeId === episodeId);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default createQueueItemSelector;
|
||||
@@ -1,5 +1,5 @@
|
||||
import Column from 'Components/Table/Column';
|
||||
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
|
||||
import { createPersist, mergeColumns } from 'Helpers/createPersist';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface EventOptions {
|
||||
columns: Column[];
|
||||
}
|
||||
|
||||
const { useOptions, setOptions, setOption } = createOptionsStore<EventOptions>(
|
||||
const eventOptionsStore = createPersist<EventOptions>(
|
||||
'event_options',
|
||||
() => {
|
||||
return {
|
||||
@@ -57,9 +57,29 @@ const { useOptions, setOptions, setOption } = createOptionsStore<EventOptions>(
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
{
|
||||
merge: mergeColumns,
|
||||
}
|
||||
);
|
||||
|
||||
export const useEventOptions = useOptions;
|
||||
export const setEventOptions = setOptions;
|
||||
export const setEventOption = setOption;
|
||||
export const useEventOptions = () => {
|
||||
return eventOptionsStore((state) => state);
|
||||
};
|
||||
|
||||
export const setEventOptions = (options: Partial<EventOptions>) => {
|
||||
eventOptionsStore.setState((state) => ({
|
||||
...state,
|
||||
...options,
|
||||
}));
|
||||
};
|
||||
|
||||
export const setEventOption = <K extends keyof EventOptions>(
|
||||
key: K,
|
||||
value: EventOptions[K]
|
||||
) => {
|
||||
eventOptionsStore.setState((state) => ({
|
||||
...state,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -81,10 +81,6 @@ async function fetchJson<T, TData>({
|
||||
throw new ApiError(path, response.status, response.statusText, body);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
return response.json() as T;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { PropertyFilter } from 'App/State/AppState';
|
||||
|
||||
export interface QueryParams {
|
||||
[key: string]:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| PropertyFilter[]
|
||||
| number[]
|
||||
| undefined;
|
||||
[key: string]: string | number | boolean | PropertyFilter[] | undefined;
|
||||
}
|
||||
|
||||
const getQueryString = (queryParams?: QueryParams) => {
|
||||
@@ -15,34 +9,27 @@ const getQueryString = (queryParams?: QueryParams) => {
|
||||
return '';
|
||||
}
|
||||
|
||||
const searchParams = Object.keys(queryParams).reduce<URLSearchParams>(
|
||||
(acc, key) => {
|
||||
const value = queryParams[key];
|
||||
|
||||
if (value == null) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (typeof value[0] === 'object') {
|
||||
(value as PropertyFilter[]).forEach((filter) => {
|
||||
acc.append(filter.key, String(filter.value));
|
||||
});
|
||||
} else {
|
||||
value.forEach((item) => {
|
||||
acc.append(key, String(item));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
acc.append(key, String(value));
|
||||
}
|
||||
const filteredParams = Object.keys(queryParams).reduce<
|
||||
Record<string, string>
|
||||
>((acc, key) => {
|
||||
const value = queryParams[key];
|
||||
|
||||
if (value == null) {
|
||||
return acc;
|
||||
},
|
||||
new URLSearchParams()
|
||||
);
|
||||
}
|
||||
|
||||
const paramsString = searchParams.toString();
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((filter) => {
|
||||
acc[filter.key] = String(filter.value);
|
||||
});
|
||||
} else {
|
||||
acc[key] = String(value);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const paramsString = new URLSearchParams(filteredParams).toString();
|
||||
|
||||
return `?${paramsString}`;
|
||||
};
|
||||
|
||||
@@ -1,25 +1,13 @@
|
||||
import KeysMatching from 'typings/Helpers/KeysMatching';
|
||||
|
||||
function selectUniqueIds<T, K>(items: T[], idProp: KeysMatching<T, K>) {
|
||||
const result = items.reduce((acc: Set<K>, item) => {
|
||||
if (!item[idProp]) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const value = item[idProp] as K;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => {
|
||||
acc.add(v);
|
||||
});
|
||||
} else {
|
||||
acc.add(value);
|
||||
return items.reduce((acc: K[], item) => {
|
||||
if (item[idProp] && acc.indexOf(item[idProp] as K) === -1) {
|
||||
acc.push(item[idProp] as K);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, new Set<K>());
|
||||
|
||||
return Array.from(result);
|
||||
}, []);
|
||||
}
|
||||
|
||||
export default selectUniqueIds;
|
||||
|
||||
@@ -29,8 +29,8 @@ interface Queue extends ModelBase {
|
||||
customFormatScore: number;
|
||||
size: number;
|
||||
title: string;
|
||||
sizeLeft: number;
|
||||
timeLeft: string;
|
||||
sizeleft: number;
|
||||
timeleft: string;
|
||||
estimatedCompletionTime: string;
|
||||
added?: string;
|
||||
status: string;
|
||||
@@ -45,11 +45,8 @@ interface Queue extends ModelBase {
|
||||
episodeHasFile: boolean;
|
||||
seriesId?: number;
|
||||
episodeId?: number;
|
||||
episodeIds: number[];
|
||||
seasonNumber?: number;
|
||||
seasonNumbers: number[];
|
||||
downloadClientHasPostImportCategory: boolean;
|
||||
isFullSeason: boolean;
|
||||
episode?: Episode;
|
||||
}
|
||||
|
||||
|
||||
@@ -390,5 +390,12 @@ namespace NzbDrone.Common.Http
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public virtual HttpRequestBuilder AllowRedirect(bool allowAutoRedirect = true)
|
||||
{
|
||||
AllowAutoRedirect = allowAutoRedirect;
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
|
||||
{
|
||||
AddPending(id: 1, seasonNumber: 2, episodes: new[] { 3 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31($"pending-{1}");
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItems(queueId);
|
||||
|
||||
@@ -82,7 +82,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
|
||||
AddPending(id: 3, seasonNumber: 2, episodes: new[] { 3 });
|
||||
AddPending(id: 4, seasonNumber: 2, episodes: new[] { 3 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31($"pending-{3}");
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 3, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItems(queueId);
|
||||
|
||||
@@ -97,7 +97,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
|
||||
AddPending(id: 3, seasonNumber: 3, episodes: new[] { 1 });
|
||||
AddPending(id: 4, seasonNumber: 3, episodes: new[] { 1 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31($"pending-{1}");
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItems(queueId);
|
||||
|
||||
@@ -112,7 +112,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
|
||||
AddPending(id: 3, seasonNumber: 2, episodes: new[] { 2 });
|
||||
AddPending(id: 4, seasonNumber: 2, episodes: new[] { 3 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31($"pending-{1}");
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItems(queueId);
|
||||
|
||||
@@ -125,7 +125,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
|
||||
AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 });
|
||||
AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1, 2 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31($"pending-{1}");
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItems(queueId);
|
||||
|
||||
@@ -138,7 +138,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
|
||||
AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 });
|
||||
AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1, 2 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31($"pending-{2}");
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 2, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItems(queueId);
|
||||
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FizzWare.NBuilder;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Common.Crypto;
|
||||
using NzbDrone.Core.Download.Pending;
|
||||
using NzbDrone.Core.Parser;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
using NzbDrone.Core.Tv;
|
||||
|
||||
namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class RemovePendingObsoleteFixture : CoreTest<PendingReleaseService>
|
||||
{
|
||||
private List<PendingRelease> _pending;
|
||||
private Episode _episode;
|
||||
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
_pending = new List<PendingRelease>();
|
||||
|
||||
_episode = Builder<Episode>.CreateNew()
|
||||
.Build();
|
||||
|
||||
Mocker.GetMock<IPendingReleaseRepository>()
|
||||
.Setup(s => s.AllBySeriesId(It.IsAny<int>()))
|
||||
.Returns(_pending);
|
||||
|
||||
Mocker.GetMock<IPendingReleaseRepository>()
|
||||
.Setup(s => s.All())
|
||||
.Returns(_pending);
|
||||
|
||||
Mocker.GetMock<ISeriesService>()
|
||||
.Setup(s => s.GetSeries(It.IsAny<int>()))
|
||||
.Returns(new Series());
|
||||
|
||||
Mocker.GetMock<ISeriesService>()
|
||||
.Setup(s => s.GetSeries(It.IsAny<IEnumerable<int>>()))
|
||||
.Returns(new List<Series> { new Series() });
|
||||
|
||||
Mocker.GetMock<IParsingService>()
|
||||
.Setup(s => s.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<Series>()))
|
||||
.Returns(new RemoteEpisode { Episodes = new List<Episode> { _episode } });
|
||||
|
||||
Mocker.GetMock<IParsingService>()
|
||||
.Setup(s => s.GetEpisodes(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<Series>(), It.IsAny<bool>(), null))
|
||||
.Returns(new List<Episode> { _episode });
|
||||
}
|
||||
|
||||
private void AddPending(int id, int seasonNumber, int[] episodes)
|
||||
{
|
||||
_pending.Add(new PendingRelease
|
||||
{
|
||||
Id = id,
|
||||
Title = "Series.Title.S01E05.abc-Sonarr",
|
||||
ParsedEpisodeInfo = new ParsedEpisodeInfo { SeasonNumber = seasonNumber, EpisodeNumbers = episodes },
|
||||
Release = Builder<ReleaseInfo>.CreateNew().Build()
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_remove_same_release()
|
||||
{
|
||||
AddPending(id: 1, seasonNumber: 2, episodes: new[] { 3 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItemsObsolete(queueId);
|
||||
|
||||
AssertRemoved(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_remove_multiple_releases_release()
|
||||
{
|
||||
AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 });
|
||||
AddPending(id: 2, seasonNumber: 2, episodes: new[] { 2 });
|
||||
AddPending(id: 3, seasonNumber: 2, episodes: new[] { 3 });
|
||||
AddPending(id: 4, seasonNumber: 2, episodes: new[] { 3 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 3, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItemsObsolete(queueId);
|
||||
|
||||
AssertRemoved(3, 4);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_remove_different_season()
|
||||
{
|
||||
AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 });
|
||||
AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1 });
|
||||
AddPending(id: 3, seasonNumber: 3, episodes: new[] { 1 });
|
||||
AddPending(id: 4, seasonNumber: 3, episodes: new[] { 1 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItemsObsolete(queueId);
|
||||
|
||||
AssertRemoved(1, 2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_remove_different_episodes()
|
||||
{
|
||||
AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 });
|
||||
AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1 });
|
||||
AddPending(id: 3, seasonNumber: 2, episodes: new[] { 2 });
|
||||
AddPending(id: 4, seasonNumber: 2, episodes: new[] { 3 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItemsObsolete(queueId);
|
||||
|
||||
AssertRemoved(1, 2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_remove_multiepisodes()
|
||||
{
|
||||
AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 });
|
||||
AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1, 2 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItemsObsolete(queueId);
|
||||
|
||||
AssertRemoved(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_remove_singleepisodes()
|
||||
{
|
||||
AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 });
|
||||
AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1, 2 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 2, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItemsObsolete(queueId);
|
||||
|
||||
AssertRemoved(2);
|
||||
}
|
||||
|
||||
private void AssertRemoved(params int[] ids)
|
||||
{
|
||||
Mocker.GetMock<IPendingReleaseRepository>().Verify(c => c.DeleteMany(It.Is<IEnumerable<int>>(s => s.SequenceEqual(ids))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
"TvrageID":"4055",
|
||||
"ImdbID":"0320037",
|
||||
"InfoHash":"123",
|
||||
"Tags": ["Subtitles"],
|
||||
"DownloadURL":"https:\/\/broadcasthe.net\/torrents.php?action=download&id=123&authkey=123&torrent_pass=123"
|
||||
},
|
||||
"1234":{
|
||||
@@ -54,8 +55,9 @@
|
||||
"TvrageID":"38472",
|
||||
"ImdbID":"2377081",
|
||||
"InfoHash":"1234",
|
||||
"Tags": [],
|
||||
"DownloadURL":"https:\/\/broadcasthe.net\/torrents.php?action=download&id=1234&authkey=1234&torrent_pass=1234"
|
||||
}},
|
||||
"results":"117927"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,5 +124,34 @@
|
||||
<newznab:attr name="nuked" value="0"/>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<title>title</title>
|
||||
<guid isPermaLink="true">subs=eng</guid>
|
||||
<link>link</link>
|
||||
<comments>comments</comments>
|
||||
<pubDate>Sat, 31 Aug 2024 12:28:40 +0300</pubDate>
|
||||
<category>category</category>
|
||||
<description>description</description>
|
||||
<enclosure url="url" length="500" type="application/x-nzb"/>
|
||||
|
||||
<newznab:attr name="haspretime" value="0"/>
|
||||
<newznab:attr name="nuked" value="0"/>
|
||||
<newznab:attr name="subs" value="Eng"/>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<title>title</title>
|
||||
<guid isPermaLink="true">subs=''</guid>
|
||||
<link>link</link>
|
||||
<comments>comments</comments>
|
||||
<pubDate>Sat, 31 Aug 2024 12:28:40 +0300</pubDate>
|
||||
<category>category</category>
|
||||
<description>description</description>
|
||||
<enclosure url="url" length="500" type="application/x-nzb"/>
|
||||
|
||||
<newznab:attr name="haspretime" value="0"/>
|
||||
<newznab:attr name="nuked" value="0"/>
|
||||
<newznab:attr name="subs" value=""/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -64,6 +64,8 @@ namespace NzbDrone.Core.Test.IndexerTests.BroadcastheNetTests
|
||||
torrentInfo.Container.Should().Be("MP4");
|
||||
torrentInfo.Codec.Should().Be("x264");
|
||||
torrentInfo.Resolution.Should().Be("SD");
|
||||
|
||||
torrentInfo.IndexerFlags.Should().HaveFlag(IndexerFlags.Subtitles);
|
||||
}
|
||||
|
||||
private void VerifyBackOff()
|
||||
|
||||
@@ -165,6 +165,8 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests
|
||||
[TestCase("nuked=0 attribute")]
|
||||
[TestCase("prematch=1 and nuked=1 attributes", IndexerFlags.Scene, IndexerFlags.Nuked)]
|
||||
[TestCase("haspretime=0 and nuked=0 attributes")]
|
||||
[TestCase("subs=eng", IndexerFlags.Subtitles)]
|
||||
[TestCase("subs=''")]
|
||||
public async Task should_parse_indexer_flags(string releaseGuid, params IndexerFlags[] indexerFlags)
|
||||
{
|
||||
var feed = ReadAllText(@"Files/Indexers/Newznab/newznab_indexerflags.xml");
|
||||
|
||||
@@ -60,7 +60,8 @@ namespace NzbDrone.Core.Test.Languages
|
||||
new object[] { 48, Language.Uzbek },
|
||||
new object[] { 49, Language.Malay },
|
||||
new object[] { 50, Language.Urdu },
|
||||
new object[] { 51, Language.Romansh }
|
||||
new object[] { 51, Language.Romansh },
|
||||
new object[] { 52, Language.Georgian }
|
||||
};
|
||||
|
||||
public static object[] ToIntCases =
|
||||
@@ -115,7 +116,8 @@ namespace NzbDrone.Core.Test.Languages
|
||||
new object[] { Language.Uzbek, 48 },
|
||||
new object[] { Language.Malay, 49 },
|
||||
new object[] { Language.Urdu, 50 },
|
||||
new object[] { Language.Romansh, 51 }
|
||||
new object[] { Language.Romansh, 51 },
|
||||
new object[] { Language.Georgian, 52 }
|
||||
};
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -580,6 +580,8 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
|
||||
[TestCase("ice", "IS")]
|
||||
[TestCase("dut", "NL")]
|
||||
[TestCase("nor", "NO")]
|
||||
[TestCase("geo", "KA")]
|
||||
[TestCase("kat", "KA")]
|
||||
public void should_format_languagecodes_properly(string language, string code)
|
||||
{
|
||||
_namingConfig.StandardEpisodeFormat = "{Series.Title}.S{season:00}E{episode:00}.{Episode.Title}.{MEDIAINFO.FULL}";
|
||||
|
||||
@@ -69,5 +69,15 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
var result = IsoLanguages.Find(isoCode);
|
||||
result.Language.Should().Be(Language.Romansh);
|
||||
}
|
||||
|
||||
[TestCase("ka")]
|
||||
[TestCase("geo")]
|
||||
[TestCase("kat")]
|
||||
[TestCase("ka-GE")]
|
||||
public void should_return_georgian(string isoCode)
|
||||
{
|
||||
var result = IsoLanguages.Find(isoCode);
|
||||
result.Language.Should().Be(Language.Georgian);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,6 +529,22 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
result.Should().Contain(Language.Romansh);
|
||||
}
|
||||
|
||||
[TestCase("Title.the.Series.2025.S01.Georgian.1080p.WEB-DL.h264-RlsGrp")]
|
||||
[TestCase("Title.the.Series.2025.S01.Geo.1080p.WEB-DL.h264-RlsGrp")]
|
||||
[TestCase("Title.the.Series.2025.S01.KA.1080p.WEB-DL.h264-RlsGrp")]
|
||||
public void should_parse_language_georgian(string postTitle)
|
||||
{
|
||||
var result = LanguageParser.ParseLanguages(postTitle);
|
||||
result.Should().Contain(Language.Georgian);
|
||||
}
|
||||
|
||||
[TestCase("Title.the.Series.2025.S01.RU-KA.1080p.WEB-DL.h264-RlsGrp")]
|
||||
public void should_parse_language_russian_and_georgian(string postTitle)
|
||||
{
|
||||
var result = LanguageParser.ParseLanguages(postTitle);
|
||||
result.Should().BeEquivalentTo(new[] { Language.Russian, Language.Georgian });
|
||||
}
|
||||
|
||||
[TestCase("Name (2020) - S01E20 - [AAC 2.0].testtitle.default.eng.forced.ass", new[] { "default", "forced" }, "testtitle", "English")]
|
||||
[TestCase("Name (2020) - S01E20 - [AAC 2.0].eng.default.testtitle.forced.ass", new[] { "default", "forced" }, "testtitle", "English")]
|
||||
[TestCase("Name (2020) - S01E20 - [AAC 2.0].default.eng.testtitle.forced.ass", new[] { "default", "forced" }, "testtitle", "English")]
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FizzWare.NBuilder;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.Download;
|
||||
using NzbDrone.Core.Download.TrackedDownloads;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Queue;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
using NzbDrone.Core.Tv;
|
||||
|
||||
namespace NzbDrone.Core.Test.QueueTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class ObsoleteQueueServiceFixture : CoreTest<ObsoleteQueueService>
|
||||
{
|
||||
private List<TrackedDownload> _trackedDownloads;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
var downloadClientInfo = Builder<DownloadClientItemClientInfo>.CreateNew().Build();
|
||||
|
||||
var downloadItem = Builder<NzbDrone.Core.Download.DownloadClientItem>.CreateNew()
|
||||
.With(v => v.RemainingTime = TimeSpan.FromSeconds(10))
|
||||
.With(v => v.DownloadClientInfo = downloadClientInfo)
|
||||
.Build();
|
||||
|
||||
var series = Builder<Series>.CreateNew()
|
||||
.Build();
|
||||
|
||||
var episodes = Builder<Episode>.CreateListOfSize(3)
|
||||
.All()
|
||||
.With(e => e.SeriesId = series.Id)
|
||||
.Build();
|
||||
|
||||
var remoteEpisode = Builder<RemoteEpisode>.CreateNew()
|
||||
.With(r => r.Series = series)
|
||||
.With(r => r.Episodes = new List<Episode>(episodes))
|
||||
.With(r => r.ParsedEpisodeInfo = new ParsedEpisodeInfo())
|
||||
.Build();
|
||||
|
||||
_trackedDownloads = Builder<TrackedDownload>.CreateListOfSize(1)
|
||||
.All()
|
||||
.With(v => v.IsTrackable = true)
|
||||
.With(v => v.DownloadItem = downloadItem)
|
||||
.With(v => v.RemoteEpisode = remoteEpisode)
|
||||
.Build()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void queue_items_should_have_id()
|
||||
{
|
||||
Subject.Handle(new TrackedDownloadRefreshedEvent(_trackedDownloads));
|
||||
|
||||
var queue = Subject.GetQueue();
|
||||
|
||||
queue.Should().HaveCount(3);
|
||||
|
||||
queue.All(v => v.Id > 0).Should().BeTrue();
|
||||
|
||||
var distinct = queue.Select(v => v.Id).Distinct().ToArray();
|
||||
|
||||
distinct.Should().HaveCount(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,9 +58,13 @@ namespace NzbDrone.Core.Test.QueueTests
|
||||
|
||||
var queue = Subject.GetQueue();
|
||||
|
||||
queue.Should().HaveCount(1);
|
||||
queue.Should().HaveCount(3);
|
||||
|
||||
queue.All(v => v.Id > 0).Should().BeTrue();
|
||||
|
||||
var distinct = queue.Select(v => v.Id).Distinct().ToArray();
|
||||
|
||||
distinct.Should().HaveCount(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,6 @@ namespace NzbDrone.Core.Download.Pending
|
||||
Queue.Queue FindPendingQueueItem(int queueId);
|
||||
void RemovePendingQueueItems(int queueId);
|
||||
RemoteEpisode OldestPendingRelease(int seriesId, int[] episodeIds);
|
||||
List<Queue.Queue> GetPendingQueueObsolete();
|
||||
Queue.Queue FindPendingQueueItemObsolete(int queueId);
|
||||
void RemovePendingQueueItemsObsolete(int queueId);
|
||||
}
|
||||
|
||||
public class PendingReleaseService : IPendingReleaseService,
|
||||
@@ -190,44 +187,7 @@ namespace NzbDrone.Core.Download.Pending
|
||||
{
|
||||
if (pendingRelease.RemoteEpisode.Episodes.Empty())
|
||||
{
|
||||
var noEpisodeItem = GetQueueItem(pendingRelease, nextRssSync, []);
|
||||
|
||||
noEpisodeItem.ErrorMessage = "Unable to find matching episode(s)";
|
||||
|
||||
queued.Add(noEpisodeItem);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
queued.Add(GetQueueItem(pendingRelease, nextRssSync, pendingRelease.RemoteEpisode.Episodes));
|
||||
}
|
||||
|
||||
// Return best quality release for each episode group, this may result in multiple for the same episode if the episodes in each release differ
|
||||
var deduped = queued.Where(q => q.Episodes.Any()).GroupBy(q => q.Episodes.Select(e => e.Id)).Select(g =>
|
||||
{
|
||||
var series = g.First().Series;
|
||||
|
||||
return g.OrderByDescending(e => e.Quality, new QualityModelComparer(series.QualityProfile))
|
||||
.ThenBy(q => PrioritizeDownloadProtocol(q.Series, q.Protocol))
|
||||
.First();
|
||||
});
|
||||
|
||||
return deduped.ToList();
|
||||
}
|
||||
|
||||
public List<Queue.Queue> GetPendingQueueObsolete()
|
||||
{
|
||||
var queued = new List<Queue.Queue>();
|
||||
|
||||
var nextRssSync = new Lazy<DateTime>(() => _taskManager.GetNextExecution(typeof(RssSyncCommand)));
|
||||
|
||||
var pendingReleases = IncludeRemoteEpisodes(_repository.WithoutFallback());
|
||||
|
||||
foreach (var pendingRelease in pendingReleases)
|
||||
{
|
||||
if (pendingRelease.RemoteEpisode.Episodes.Empty())
|
||||
{
|
||||
var noEpisodeItem = GetQueueItem(pendingRelease, nextRssSync, (Episode)null);
|
||||
var noEpisodeItem = GetQueueItem(pendingRelease, nextRssSync, null);
|
||||
|
||||
noEpisodeItem.ErrorMessage = "Unable to find matching episode(s)";
|
||||
|
||||
@@ -242,18 +202,15 @@ namespace NzbDrone.Core.Download.Pending
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning disable CS0612
|
||||
|
||||
// Return best quality release for each episode
|
||||
var deduped = queued.Where(q => q.Episode != null).GroupBy(q => q.Episode.Id).Select(g =>
|
||||
{
|
||||
var series = g.First().Series;
|
||||
|
||||
return g.OrderByDescending(e => e.Quality, new QualityModelComparer(series.QualityProfile))
|
||||
.ThenBy(q => PrioritizeDownloadProtocol(q.Series, q.Protocol))
|
||||
.First();
|
||||
.ThenBy(q => PrioritizeDownloadProtocol(q.Series, q.Protocol))
|
||||
.First();
|
||||
});
|
||||
#pragma warning restore CS0612
|
||||
|
||||
return deduped.ToList();
|
||||
}
|
||||
@@ -263,11 +220,6 @@ namespace NzbDrone.Core.Download.Pending
|
||||
return GetPendingQueue().SingleOrDefault(p => p.Id == queueId);
|
||||
}
|
||||
|
||||
public Queue.Queue FindPendingQueueItemObsolete(int queueId)
|
||||
{
|
||||
return GetPendingQueue().SingleOrDefault(p => p.Id == queueId);
|
||||
}
|
||||
|
||||
public void RemovePendingQueueItems(int queueId)
|
||||
{
|
||||
var targetItem = FindPendingRelease(queueId);
|
||||
@@ -280,18 +232,6 @@ namespace NzbDrone.Core.Download.Pending
|
||||
_repository.DeleteMany(releasesToRemove.Select(c => c.Id));
|
||||
}
|
||||
|
||||
public void RemovePendingQueueItemsObsolete(int queueId)
|
||||
{
|
||||
var targetItem = FindPendingReleaseObsolete(queueId);
|
||||
var seriesReleases = _repository.AllBySeriesId(targetItem.SeriesId);
|
||||
|
||||
var releasesToRemove = seriesReleases.Where(
|
||||
c => c.ParsedEpisodeInfo.SeasonNumber == targetItem.ParsedEpisodeInfo.SeasonNumber &&
|
||||
c.ParsedEpisodeInfo.EpisodeNumbers.SequenceEqual(targetItem.ParsedEpisodeInfo.EpisodeNumbers));
|
||||
|
||||
_repository.DeleteMany(releasesToRemove.Select(c => c.Id));
|
||||
}
|
||||
|
||||
public RemoteEpisode OldestPendingRelease(int seriesId, int[] episodeIds)
|
||||
{
|
||||
var seriesReleases = GetPendingReleases(seriesId);
|
||||
@@ -406,59 +346,6 @@ namespace NzbDrone.Core.Download.Pending
|
||||
return result;
|
||||
}
|
||||
|
||||
private Queue.Queue GetQueueItem(PendingRelease pendingRelease, Lazy<DateTime> nextRssSync, List<Episode> episodes)
|
||||
{
|
||||
var ect = pendingRelease.Release.PublishDate.AddMinutes(GetDelay(pendingRelease.RemoteEpisode));
|
||||
|
||||
if (ect < nextRssSync.Value)
|
||||
{
|
||||
ect = nextRssSync.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
ect = ect.AddMinutes(_configService.RssSyncInterval);
|
||||
}
|
||||
|
||||
var timeLeft = ect.Subtract(DateTime.UtcNow);
|
||||
|
||||
if (timeLeft.TotalSeconds < 0)
|
||||
{
|
||||
timeLeft = TimeSpan.Zero;
|
||||
}
|
||||
|
||||
string downloadClientName = null;
|
||||
var indexer = _indexerFactory.Find(pendingRelease.Release.IndexerId);
|
||||
|
||||
if (indexer is { DownloadClientId: > 0 })
|
||||
{
|
||||
var downloadClient = _downloadClientFactory.Find(indexer.DownloadClientId);
|
||||
|
||||
downloadClientName = downloadClient?.Name;
|
||||
}
|
||||
|
||||
var queue = new Queue.Queue
|
||||
{
|
||||
Id = GetQueueId(pendingRelease),
|
||||
Series = pendingRelease.RemoteEpisode.Series,
|
||||
Episodes = episodes,
|
||||
Languages = pendingRelease.RemoteEpisode.Languages,
|
||||
Quality = pendingRelease.RemoteEpisode.ParsedEpisodeInfo.Quality,
|
||||
Title = pendingRelease.Title,
|
||||
Size = pendingRelease.RemoteEpisode.Release.Size,
|
||||
SizeLeft = pendingRelease.RemoteEpisode.Release.Size,
|
||||
RemoteEpisode = pendingRelease.RemoteEpisode,
|
||||
TimeLeft = timeLeft,
|
||||
EstimatedCompletionTime = ect,
|
||||
Added = pendingRelease.Added,
|
||||
Status = Enum.TryParse(pendingRelease.Reason.ToString(), out QueueStatus outValue) ? outValue : QueueStatus.Unknown,
|
||||
Protocol = pendingRelease.RemoteEpisode.Release.DownloadProtocol,
|
||||
Indexer = pendingRelease.RemoteEpisode.Release.Indexer,
|
||||
DownloadClient = downloadClientName
|
||||
};
|
||||
|
||||
return queue;
|
||||
}
|
||||
|
||||
private Queue.Queue GetQueueItem(PendingRelease pendingRelease, Lazy<DateTime> nextRssSync, Episode episode)
|
||||
{
|
||||
var ect = pendingRelease.Release.PublishDate.AddMinutes(GetDelay(pendingRelease.RemoteEpisode));
|
||||
@@ -493,11 +380,7 @@ namespace NzbDrone.Core.Download.Pending
|
||||
{
|
||||
Id = GetQueueId(pendingRelease, episode),
|
||||
Series = pendingRelease.RemoteEpisode.Series,
|
||||
|
||||
#pragma warning disable CS0612
|
||||
Episode = episode,
|
||||
#pragma warning restore CS0612
|
||||
|
||||
Languages = pendingRelease.RemoteEpisode.Languages,
|
||||
Quality = pendingRelease.RemoteEpisode.ParsedEpisodeInfo.Quality,
|
||||
Title = pendingRelease.Title,
|
||||
@@ -601,20 +484,10 @@ namespace NzbDrone.Core.Download.Pending
|
||||
}
|
||||
|
||||
private PendingRelease FindPendingRelease(int queueId)
|
||||
{
|
||||
return GetPendingReleases().First(p => GetQueueId(p) == queueId);
|
||||
}
|
||||
|
||||
private PendingRelease FindPendingReleaseObsolete(int queueId)
|
||||
{
|
||||
return GetPendingReleases().First(p => p.RemoteEpisode.Episodes.Any(e => queueId == GetQueueId(p, e)));
|
||||
}
|
||||
|
||||
private int GetQueueId(PendingRelease pendingRelease)
|
||||
{
|
||||
return HashConverter.GetHashInt31(string.Format("pending-{0}", pendingRelease.Id));
|
||||
}
|
||||
|
||||
private int GetQueueId(PendingRelease pendingRelease, Episode episode)
|
||||
{
|
||||
return HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", pendingRelease.Id, episode?.Id ?? 0));
|
||||
|
||||
@@ -72,7 +72,7 @@ namespace NzbDrone.Core.ImportLists.Custom
|
||||
}
|
||||
|
||||
var baseUrl = settings.BaseUrl.TrimEnd('/');
|
||||
var request = new HttpRequestBuilder(baseUrl).Accept(HttpAccept.Json).Build();
|
||||
var request = new HttpRequestBuilder(baseUrl).Accept(HttpAccept.Json).AllowRedirect().Build();
|
||||
var response = _httpClient.Get(request);
|
||||
var results = JsonConvert.DeserializeObject<List<TResource>>(response.Content);
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ namespace NzbDrone.Core.IndexerSearch
|
||||
episodes = _episodeService.EpisodesWithoutFiles(pagingSpec).Records.ToList();
|
||||
}
|
||||
|
||||
var queue = GetQueuedEpisodeIds();
|
||||
var queue = _queueService.GetQueue().Where(q => q.Episode != null).Select(q => q.Episode.Id);
|
||||
var missing = episodes.Where(e => !queue.Contains(e.Id)).ToList();
|
||||
|
||||
SearchForBulkEpisodes(missing, monitored, message.Trigger == CommandTrigger.Manual).GetAwaiter().GetResult();
|
||||
@@ -188,18 +188,10 @@ namespace NzbDrone.Core.IndexerSearch
|
||||
}
|
||||
|
||||
var episodes = _episodeCutoffService.EpisodesWhereCutoffUnmet(pagingSpec).Records.ToList();
|
||||
var queue = GetQueuedEpisodeIds();
|
||||
var queue = _queueService.GetQueue().Where(q => q.Episode != null).Select(q => q.Episode.Id);
|
||||
var cutoffUnmet = episodes.Where(e => !queue.Contains(e.Id)).ToList();
|
||||
|
||||
SearchForBulkEpisodes(cutoffUnmet, monitored, message.Trigger == CommandTrigger.Manual).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
private List<int> GetQueuedEpisodeIds()
|
||||
{
|
||||
return _queueService.GetQueue()
|
||||
.Where(q => q.Episodes.Any())
|
||||
.SelectMany(q => q.Episodes.Select(e => e.Id))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,11 @@ namespace NzbDrone.Core.Indexers.BroadcastheNet
|
||||
break;
|
||||
}
|
||||
|
||||
if (item.Tags?.Contains("Subtitles") == true)
|
||||
{
|
||||
flags |= IndexerFlags.Subtitles;
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace NzbDrone.Core.Indexers.BroadcastheNet
|
||||
{
|
||||
public class BroadcastheNetTorrent
|
||||
@@ -26,6 +28,7 @@ namespace NzbDrone.Core.Indexers.BroadcastheNet
|
||||
public int? TvrageID { get; set; }
|
||||
public string ImdbID { get; set; }
|
||||
public string InfoHash { get; set; }
|
||||
public List<string> Tags { get; set; }
|
||||
public string DownloadURL { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +210,11 @@ namespace NzbDrone.Core.Indexers.Newznab
|
||||
flags |= IndexerFlags.Nuked;
|
||||
}
|
||||
|
||||
if (TryGetNewznabAttribute(item, "subs").IsNotNullOrWhiteSpace())
|
||||
{
|
||||
flags |= IndexerFlags.Subtitles;
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ namespace NzbDrone.Core.Languages
|
||||
public static Language Malay => new Language(49, "Malay");
|
||||
public static Language Urdu => new Language(50, "Urdu");
|
||||
public static Language Romansh => new Language(51, "Romansh");
|
||||
public static Language Georgian => new Language(52, "Georgian");
|
||||
public static Language Original => new Language(-2, "Original");
|
||||
|
||||
public static List<Language> All
|
||||
@@ -182,6 +183,7 @@ namespace NzbDrone.Core.Languages
|
||||
Malay,
|
||||
Urdu,
|
||||
Romansh,
|
||||
Georgian,
|
||||
Original
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
"SeasonDetails": "Детайли за сезона",
|
||||
"SeasonCount": "Брой сезони",
|
||||
"SslPort": "SSL порт",
|
||||
"AppDataLocationHealthCheckMessage": "Актуализирането няма да бъде възможно, за да се предотврати изтриването на папката на приложението по време на актуализацията",
|
||||
"AppDataLocationHealthCheckMessage": "Актуализацията няма да бъде възможна, за да се предотврати изтриването на AppData при обновяване",
|
||||
"AppUpdated": "{appName} Актуализиран",
|
||||
"ApplyTagsHelpTextReplace": "Замяна: Заменете таговете с въведените тагове (не въвеждайте тагове, за да изчистите всички тагове)",
|
||||
"AudioLanguages": "Аудио езици",
|
||||
@@ -139,5 +139,53 @@
|
||||
"StandardEpisodeFormat": "Формат на епизода ( Стандартен )",
|
||||
"SslCertPathHelpText": "Път до \"pfx\" файл",
|
||||
"EpisodeNaming": "Именуване на епизоди",
|
||||
"Close": "Затвори"
|
||||
"Close": "Затвори",
|
||||
"AutoTaggingSpecificationMaximumYear": "Максимална година",
|
||||
"AutoTaggingSpecificationMinimumYear": "Минимална година",
|
||||
"AutoTaggingSpecificationTag": "Етикет",
|
||||
"AutoTaggingSpecificationNetwork": "Мрежа(и)",
|
||||
"Airs": "Излъчва се",
|
||||
"BranchUpdateMechanism": "Клон, използван от външен механизъм за актуализация",
|
||||
"BranchUpdate": "Клон, който да се използва за актуализиране на {appName}",
|
||||
"BindAddressHelpText": "Валиден IP адрес, localhost или '*' за всички интерфейси",
|
||||
"BlackholeWatchFolder": "Наблюдавана папка",
|
||||
"BlackholeWatchFolderHelpText": "Папка, от която {appName} трябва да импортира завършените изтегляния",
|
||||
"BlackholeFolderHelpText": "Папка, в която {appName} ще съхранява файла {extension}",
|
||||
"BlocklistAndSearchHint": "Започнете търсене на заместител след блокиране",
|
||||
"BlocklistAndSearchMultipleHint": "Започнете търсене на заместители след блокиране",
|
||||
"BlocklistAndSearch": "Списък за блокиране и търсене",
|
||||
"Backups": "Архиви",
|
||||
"AutoTaggingSpecificationRootFolder": "Основна директория",
|
||||
"BlocklistFilterHasNoItems": "Избраният филтър за блокиране не съдържа елементи",
|
||||
"Branch": "Клон",
|
||||
"BuiltIn": "Вграден",
|
||||
"BeforeUpdate": "Преди актуализация",
|
||||
"BlocklistOnly": "Само списък за блокиране",
|
||||
"BlocklistMultipleOnlyHint": "Списък за блокиране без търсене на заместители",
|
||||
"BlocklistReleases": "Освобождаване на черния списък",
|
||||
"BlocklistRelease": "Освобождаване на черния списък",
|
||||
"Automatic": "Автоматично",
|
||||
"BackupsLoadError": "Архивите не могат да се заредят",
|
||||
"BindAddress": "Адрес за обвързване",
|
||||
"BlocklistLoadError": "Неуспешно зареждане на списъка за блокиране",
|
||||
"BackupIntervalHelpText": "Интервал между автоматичните резервни копия",
|
||||
"AutoTaggingSpecificationQualityProfile": "Профил за качество",
|
||||
"AutoTaggingSpecificationSeriesType": "Тип сериал",
|
||||
"AutoTaggingSpecificationGenre": "Жанр(ове)",
|
||||
"AutoTaggingSpecificationOriginalLanguage": "Език",
|
||||
"AutoTaggingSpecificationStatus": "Статус",
|
||||
"Backup": "Архивиране",
|
||||
"BackupFolderHelpText": "Вариант 1: Относителните пътища ще се намират в директорията AppData на {appName}",
|
||||
"BackupNow": "Архивиране сега",
|
||||
"AutoTaggingRequiredHelpText": "Условието {implementationName} трябва да съвпада, за да се приложи правилото за автоматично тагване. В противен случай е достатъчно едно съвпадение на {implementationName}.",
|
||||
"AutomaticAdd": "Автоматично добавяне",
|
||||
"AutomaticUpdatesDisabledDocker": "Автоматичните актуализации не се поддържат директно при използване на механизма за актуализация на Docker. Ще трябва да актуализирате Image-a на контейнера извън {appName} или да използвате скрипт",
|
||||
"AutomaticSearch": "Автоматично търсене",
|
||||
"BackupRetentionHelpText": "Автоматичните резервни копия, по-стари от зададения период на съхранение, ще бъдат изтрити автоматично",
|
||||
"BypassDelayIfAboveCustomFormatScore": "Пропусни, ако е над рейтинга на персонализирания формат",
|
||||
"AbsoluteEpisodeNumbers": "Абсолютен номер на епизод(и)",
|
||||
"BlocklistReleaseHelpText": "Блокира този релийз, така че {appName} да не го изтегля повторно чрез RSS или автоматично търсене",
|
||||
"Blocklist": "Списък за блокиране",
|
||||
"BrowserReloadRequired": "Необходимо е презареждане на браузъра",
|
||||
"BlocklistOnlyHint": "Списък за блокиране без търсене на заместител"
|
||||
}
|
||||
|
||||
@@ -1505,7 +1505,7 @@
|
||||
"IndexerSettingsCookieHelpText": "Si el vostre lloc requereix una galeta d'inici de sessió per accedir als rss, haureu de recuperar-la a través d'un navegador.",
|
||||
"IndexerSettingsMultiLanguageRelease": "Multiidiomes",
|
||||
"IndexerSettingsFailDownloadsHelpText": "Mentre es processen les descàrregues completades, {appName} tractarà aquests tipus d'arxiu seleccionats com a descàrregues fallides.",
|
||||
"IndexerSettingsSeasonPackSeedTimeHelpText": "La quantitat de temps que un torrent de pack de temporada s'ha de sembrar abans d'aturar-se, deixeu en blanc per utilitzar el valor per defecte del client de baixada",
|
||||
"IndexerSettingsSeasonPackSeedTimeHelpText": "La quantitat de temps que un torrent de paquet de temporada s'ha de sembrar abans d'aturar-se, deixeu en blanc per utilitzar el valor per defecte del client de baixada",
|
||||
"IndexerValidationCloudFlareCaptchaRequired": "Lloc protegit per CloudFlare CAPTCHA. Cal un testimoni CAPTCHA vàlid.",
|
||||
"IndexerValidationFeedNotSupported": "No s'admet el canal Indexer: {exceptionMessage}",
|
||||
"IndexerValidationInvalidApiKey": "Clau API no vàlida",
|
||||
@@ -1689,7 +1689,7 @@
|
||||
"UpgradeUntilThisQualityIsMetOrExceeded": "Actualitza fins que aquesta qualitat es compleixi o superi",
|
||||
"UpgradesAllowedHelpText": "Si es desactiven les qualitats no s'actualitzaran",
|
||||
"IndexerSettingsFailDownloads": "Baixades fallides",
|
||||
"IndexerSettingsSeasonPackSeedTime": "Hora de la llavor de les packs de temporada",
|
||||
"IndexerSettingsSeasonPackSeedTime": "Hora de la llavor dels paquets de temporada",
|
||||
"IndexerValidationNoRssFeedQueryAvailable": "No hi ha disponible cap consulta de fonts RSS. Aquest pot ser un problema amb l'indexador o la configuració de la categoria de l'indexador.",
|
||||
"LibraryImportTipsSeriesUseRootFolder": "Apunteu {appName} a la carpeta que conté totes les sèries, no una específica. ex. \"`{goodFolderExample}`\" i no eg. \"`{badFolderExample}`\". A més, cada sèrie ha d'estar en la seva pròpia carpeta dins de la carpeta arrel/biblioteca.",
|
||||
"LocalAirDate": "Data d'emissió local",
|
||||
@@ -2170,5 +2170,19 @@
|
||||
"UserRejectedExtensionsHelpText": "Llista d'extensions d'arxiu a fallar separades per coma (Descàrregues fallides també necessita ser activat per indexador)",
|
||||
"UserRejectedExtensionsTextsExamples": "Exemples: '.ext, .xyz' o 'ext,xyz'",
|
||||
"DownloadClientQbittorrentSettingsAddSeriesTags": "Afegeix etiquetes de sèries",
|
||||
"DownloadClientQbittorrentSettingsAddSeriesTagsHelpText": "Afegeix etiquetes de sèries als nous torrents afegits al client de descàrrega (qBittorrent 4.1.0+)"
|
||||
"DownloadClientQbittorrentSettingsAddSeriesTagsHelpText": "Afegeix etiquetes de sèries als nous torrents afegits al client de descàrrega (qBittorrent 4.1.0+)",
|
||||
"IndexerSettingsSeasonPackSeedGoal": "Objectiu de compartició per als paquets de temporada",
|
||||
"IndexerSettingsSeasonPackSeedGoalHelpText": "Trieu si utilitzar diferents objectius de llavor per als paquets de temporada",
|
||||
"IndexerSettingsSeasonPackSeedGoalUseStandardGoals": "Utilitzeu els objectius estàndard",
|
||||
"IndexerSettingsSeasonPackSeedGoalUseSeasonPackGoals": "Utilitzeu els objectius del paquet de temporada",
|
||||
"IndexerSettingsSeasonPackSeedRatio": "Ràtio de la llavor del paquet de temporada",
|
||||
"IndexerSettingsSeasonPackSeedRatioHelpText": "La relació a la qual ha d'arribar el torrent d'un paquet de temporada abans d'aturar-se, buida usa el valor predeterminat del client de baixada. La relació ha de ser com a mínim 1.0 i seguir les regles dels indexadors",
|
||||
"RemoveRootFolderWithSeriesMessageText": "Esteu segur que voleu eliminar la carpeta arrel '{path}'? Els fitxers i carpetes no s'eliminaran del disc, i les sèries d'aquesta carpeta arrel no s'eliminaran de {appName}.",
|
||||
"DownloadClientTriblerSettingsAnonymityLevel": "Nivell d'anonimat",
|
||||
"DownloadClientTriblerSettingsAnonymityLevelHelpText": "Nombre de proxies a usar quan es descarrega contingut. Establir a 0 per a desactivar-ho. Els proxies redueixen la velocitat de descàrrega/pujada. Vegeu {url}",
|
||||
"DownloadClientTriblerSettingsApiKeyHelpText": "[api].key de triblerd.conf",
|
||||
"DownloadClientTriblerSettingsDirectoryHelpText": "Ubicació opcional per a desar les baixades, deixeu-ho en blanc per utilitzar la ubicació predeterminada del Tribler",
|
||||
"DownloadClientTriblerSettingsSafeSeeding": "Compartició segura",
|
||||
"DownloadClientTriblerSettingsSafeSeedingHelpText": "Quan està activat, només es comparteix a través de proxies.",
|
||||
"DownloadClientTriblerProviderMessage": "La integració tribler és altament experimental. S'ha provat amb {clientName} versió {clientVersionRange}."
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@
|
||||
"Any": "Jakákoliv",
|
||||
"ApiKey": "Klíč API",
|
||||
"AppUpdated": "{appName} aktualizován",
|
||||
"AppUpdatedVersion": "{appName} byla aktualizována na verzi `{version}`, abyste získali nejnovější změny, musíte znovu načíst {appName}. ",
|
||||
"AppUpdatedVersion": "{appName} byl aktualizován na verzi `{version}`. Abyste získali nejnovější změny, musíte znovu načíst {appName} ",
|
||||
"ChooseImportMode": "Vyberte mód importu",
|
||||
"ClickToChangeEpisode": "Kliknutím změníte epizodu",
|
||||
"ClickToChangeLanguage": "Kliknutím změníte jazyk",
|
||||
@@ -316,7 +316,7 @@
|
||||
"EditSelectedImportLists": "Upravit vybrané seznamy k importu",
|
||||
"FormatDateTime": "{formattedDate} {formattedTime}",
|
||||
"AddRootFolderError": "Nepodařilo se přidat kořenový adresář",
|
||||
"DownloadClientRemovesCompletedDownloadsHealthCheckMessage": "Klient stahování {downloadClientName} je nastaven na odstranění dokončených stahování. To může vést k tomu, že stahování budou z klienta odstraněna dříve, než je bude moci importovat {appName}.",
|
||||
"DownloadClientRemovesCompletedDownloadsHealthCheckMessage": "Klient pro stahování {downloadClientName} je nastaven, aby odstraňoval dokončené stahování. To může vést k tomu, že stažená data budou z klienta odstraněna dříve, než je {appName} bude moci importovat.",
|
||||
"ConnectionSettingsUrlBaseHelpText": "Přidá předponu do {connectionName} url, jako např. {url}",
|
||||
"CustomFormatsSpecificationRegularExpressionHelpText": "Vlastní formát RegEx nerozlišuje velká a malá písmena",
|
||||
"CustomFormatsSpecificationFlag": "Značka",
|
||||
@@ -357,13 +357,13 @@
|
||||
"DeleteSpecification": "Smaž specifikace",
|
||||
"MappedNetworkDrivesWindowsService": "Mapované síťové jednotky nejsou k dispozici, když běží jako služba Windows. Další informace najdete v [FAQ]({url}).",
|
||||
"DeletedSeriesDescription": "Seriál byl smazán z TheTVDB",
|
||||
"RecycleBinUnableToWriteHealthCheckMessage": "Nelze zapisovat do nakonfigurované složky koše: {path}. Ujistěte se, že tato cesta existuje a že do ní může zapisovat uživatel se spuštěnou {appName}",
|
||||
"RecycleBinUnableToWriteHealthCheckMessage": "Nelze zapisovat do nakonfigurované složky koše: {path}. Ujistěte se, že tato cesta existuje a že do ní může zapisovat uživatel, pod kterým běží {appName}",
|
||||
"DeleteSelectedImportListExclusionsMessageText": "Opravdu smazat vybraný importovaný seznam vyjímek?",
|
||||
"DoNotUpgradeAutomatically": "Neupgradovat automaticky",
|
||||
"DownloadClientQbittorrentSettingsInitialStateHelpText": "Počáteční stav torrentů přidaných do qBittorrentu. Pamatujte, že vynucené torrenty nedodržují omezení týkající se seedů",
|
||||
"DeleteSelectedCustomFormats": "Smazat vlastní formát(y)",
|
||||
"ClickToChangeReleaseType": "Kliknutím změníte typ verze",
|
||||
"CollapseAll": "Sbal Všechno",
|
||||
"CollapseAll": "Sbalit vše",
|
||||
"CutoffUnmetNoItems": "Žádné neodpovídající nesplněné položky",
|
||||
"CutoffUnmetLoadError": "Chybné načítání nesplněných položek",
|
||||
"AddDelayProfileError": "Nelze přidat nový profil zpoždění, zkuste to prosím znovu.",
|
||||
@@ -401,7 +401,7 @@
|
||||
"DoneEditingGroups": "Úpravy skupin dokončeny",
|
||||
"DeleteSeriesModalHeader": "Smazat - {title}",
|
||||
"DeleteSelectedCustomFormatsMessageText": "Opravdu odstranit {count} vybraný vlastní formát(y)?",
|
||||
"DownloadClientCheckNoneAvailableHealthCheckMessage": "Nedostupný klient pro stahování",
|
||||
"DownloadClientCheckNoneAvailableHealthCheckMessage": "Není dostupný žádný klient pro stahování",
|
||||
"DownloadClientCheckUnableToCommunicateWithHealthCheckMessage": "Nelze komunikovat s {downloadClientName}. {errorMessage}",
|
||||
"DownloadClientFreeboxSettingsAppTokenHelpText": "Token aplikace získaný při vytváření přístupu k Freebox API (tj. ‚app_token‘)",
|
||||
"DownloadClientFreeboxSettingsAppToken": "Token aplikace",
|
||||
@@ -560,5 +560,19 @@
|
||||
"Path": "Cesta",
|
||||
"RegularExpressionsCanBeTested": "Regulární výrazy lze testovat [zde]({url}).",
|
||||
"NotificationStatusSingleClientHealthCheckMessage": "Oznámení nedostupná z důvodu selhání: {notificationNames}",
|
||||
"NotificationTriggersHelpText": "Vyber, které události mají vyvolat toto upozornění"
|
||||
"NotificationTriggersHelpText": "Vyber, které události mají vyvolat toto upozornění",
|
||||
"MonitorSelected": "Monitorovat vybrané",
|
||||
"ShowBannersHelpText": "Zobrazit bannery místo jmen",
|
||||
"ListRootFolderHelpText": "Kořenová složka, do které budou přidány položky seznamu",
|
||||
"UnmonitorSelected": "Nemonitorovat vybrané",
|
||||
"ErrorLoadingItem": "Nastala chyba při načítání této položky",
|
||||
"IndexerJackettAllHealthCheckMessage": "Indexery, které používají nepodporovaný Jackett endpoint 'all': {indexerNames}",
|
||||
"ReleaseProfileIndexerHelpText": "Výběr, jakých indexerů se profil týká",
|
||||
"SkipRedownloadHelpText": "Zabraňuje {appName} zkoušet stahovat alternativní vydání pro odebrané položky",
|
||||
"ErrorLoadingPage": "Nastala chyba při načítání této stránky",
|
||||
"ExpandAll": "Rozbalit vše",
|
||||
"ImportListsPlexSettingsAuthenticateWithPlex": "Autentizovat s Plex.tv",
|
||||
"MissingNoItems": "Žádné chybějící položky",
|
||||
"RegularExpression": "Regulární výraz",
|
||||
"IndexerSearchNoInteractiveHealthCheckMessage": "Nejsou dostupné žádné indexery s povoleným interaktivním vyhledáváním, {appName} nemůže poskytnout žádné výsledky interaktivního hledání"
|
||||
}
|
||||
|
||||
@@ -662,7 +662,6 @@
|
||||
"EpisodeInfo": "Episode Info",
|
||||
"EpisodeIsDownloading": "Episode is downloading",
|
||||
"EpisodeIsNotMonitored": "Episode is not monitored",
|
||||
"EpisodeMaybePlural": "Episode(s)",
|
||||
"EpisodeMissingAbsoluteNumber": "Episode does not have an absolute episode number",
|
||||
"EpisodeMissingFromDisk": "Episode missing from disk",
|
||||
"EpisodeMonitoring": "Episode Monitoring",
|
||||
@@ -672,8 +671,6 @@
|
||||
"EpisodeRequested": "Episode Requested",
|
||||
"EpisodeSearchResultsLoadError": "Unable to load results for this episode search. Try again later",
|
||||
"EpisodeTitle": "Episode Title",
|
||||
"EpisodeTitles": "Episode Titles",
|
||||
"EpisodeTitleMaybePlural": "Episode Title(s)",
|
||||
"EpisodeTitleFootNote": "Optionally control truncation to a maximum number of bytes including ellipsis (`...`). Truncating from the end (e.g. `{Episode Title:30}`) or the beginning (e.g. `{Episode Title:-30}`) are both supported. Episode titles will be automatically truncated to file system limitations if necessary.",
|
||||
"EpisodeTitleRequired": "Episode Title Required",
|
||||
"EpisodeTitleRequiredHelpText": "Prevent importing for up to 48 hours if the episode title is in the naming format and the episode title is TBA",
|
||||
@@ -1289,7 +1286,6 @@
|
||||
"MultiEpisodeStyle": "Multi Episode Style",
|
||||
"MultiLanguages": "Multi-Languages",
|
||||
"MultiSeason": "Multi-Season",
|
||||
"MultipleEpisodes": "Multiple Episodes",
|
||||
"MustContain": "Must Contain",
|
||||
"MustContainHelpText": "The release must contain at least one of these terms (case insensitive)",
|
||||
"MustNotContain": "Must Not Contain",
|
||||
|
||||
@@ -474,7 +474,7 @@
|
||||
"DelayProfiles": "Perfiles de retraso",
|
||||
"DeleteCustomFormatMessageText": "¿Estás seguro que quieres eliminar el formato personalizado '{name}'?",
|
||||
"DeleteBackup": "Eliminar copia de seguridad",
|
||||
"CopyUsingHardlinksSeriesHelpText": "Los hardlinks permiten a {appName} a importar los torrents que se estén compartiendo a la carpeta de la serie sin usar espacio adicional en el disco o sin copiar el contenido completo del archivo. Los hardlinks solo funcionarán si el origen y el destino están en el mismo volumen",
|
||||
"CopyUsingHardlinksSeriesHelpText": "Los enlaces físicos permiten a {appName} importar los torrents que se estén sembrando a la carpeta de la serie sin usar espacio adicional en el disco o copiar el contenido completo del archivo. Los enlaces físicos solo funcionarán si el origen y el destino están en el mismo volumen",
|
||||
"DefaultDelayProfileSeries": "Este es el perfil por defecto. Aplica a todas las series que no tienen un perfil explícito.",
|
||||
"DelayProfileSeriesTagsHelpText": "Aplica a series con al menos una etiqueta coincidente",
|
||||
"DeleteCustomFormat": "Eliminar formato personalizado",
|
||||
@@ -526,7 +526,7 @@
|
||||
"DeleteEpisodesFilesHelpText": "Eliminar archivos de episodios y directorio de series",
|
||||
"DoNotPrefer": "No preferir",
|
||||
"DoNotUpgradeAutomatically": "No actualizar automáticamente",
|
||||
"IndexerSettingsSeedRatioHelpText": "El ratio que un torrent debería alcanzar antes de parar, vacío usa el predeterminado del cliente de descarga. El ratio debería ser al menos 1.0 y seguir las reglas de los indexadores",
|
||||
"IndexerSettingsSeedRatioHelpText": "El ratio que un torrent debería alcanzar antes de detenerse, dejar vacío utiliza el valor predeterminado del cliente de descarga. El ratio debería ser al menos 1.0 y seguir las reglas de los indexadores",
|
||||
"Download": "Descargar",
|
||||
"Donate": "Donar",
|
||||
"DownloadClientDelugeValidationLabelPluginFailure": "La configuración de etiqueta falló",
|
||||
@@ -730,7 +730,7 @@
|
||||
"DownloadFailedEpisodeTooltip": "La descarga del episodio falló",
|
||||
"DownloadClientQbittorrentSettingsFirstAndLastFirstHelpText": "Descarga primero las primeras y últimas piezas (qBittorrent 4.1.0+)",
|
||||
"DownloadClientQbittorrentSettingsFirstAndLastFirst": "Primeras y últimas primero",
|
||||
"DownloadClientQbittorrentSettingsInitialStateHelpText": "Estado inicial para los torrents añadidos a qBittorrent. Ten en cuenta que Forzar torrents no cumple las restricciones de semilla",
|
||||
"DownloadClientQbittorrentSettingsInitialStateHelpText": "Estado inicial para los torrents añadidos a qBittorrent. Ten en cuenta que Forzar torrents no cumple las restricciones de sembrado",
|
||||
"DownloadClientQbittorrentSettingsSequentialOrderHelpText": "Descarga en orden secuencial (qBittorrent 4.1.0+)",
|
||||
"DownloadClientDownloadStationValidationSharedFolderMissingDetail": "El Diskstation no tiene una carpeta compartida con el nombre '{sharedFolder}'. ¿Estás seguro que lo has especificado correctamente?",
|
||||
"EnableCompletedDownloadHandlingHelpText": "Importa automáticamente las descargas completas del gestor de descargas",
|
||||
@@ -771,7 +771,7 @@
|
||||
"EpisodeIsNotMonitored": "El episodio no está monitorizado",
|
||||
"EpisodesLoadError": "No se puede cargar los episodios",
|
||||
"File": "Archivo",
|
||||
"HardlinkCopyFiles": "Enlace permanente/Copiar archivos",
|
||||
"HardlinkCopyFiles": "Enlace físico/Copiar archivos",
|
||||
"EpisodeDownloaded": "Episodio descargado",
|
||||
"FileBrowser": "Explorador de archivos",
|
||||
"FilterDoesNotStartWith": "no empieza con",
|
||||
@@ -1075,11 +1075,11 @@
|
||||
"StartupDirectory": "Directorio de Arranque",
|
||||
"IndexerSettingsAdditionalParametersNyaa": "Parámetros Adicionales",
|
||||
"IndexerSettingsPasskey": "Clave de acceso",
|
||||
"IndexerSettingsSeasonPackSeedTime": "Tiempo de Semillado de los Pack de Temporada",
|
||||
"IndexerSettingsSeasonPackSeedTime": "Tiempo de sembrado de los pack de temporada",
|
||||
"IndexerSettingsAnimeStandardFormatSearch": "Formato Estándar de Búsqueda de Anime",
|
||||
"IndexerSettingsAnimeStandardFormatSearchHelpText": "Buscar también anime utilizando la numeración estándar",
|
||||
"IndexerSettingsApiPathHelpText": "Ruta a la API, usualmente {url}",
|
||||
"IndexerSettingsSeasonPackSeedTimeHelpText": "La cantidad de tiempo que un torrent de pack de temporada debe ser compartido antes de que se detenga, dejar vacío utiliza el valor por defecto del cliente de descarga",
|
||||
"IndexerSettingsSeasonPackSeedTimeHelpText": "El tiempo que un torrent de pack de temporada debe ser sembrado antes de detenerse, si se deja vacío utiliza el valor predeterminado del cliente de descarga",
|
||||
"IndexerSettingsSeedTime": "Tiempo de sembrado",
|
||||
"IndexerStatusAllUnavailableHealthCheckMessage": "Todos los indexadores no están disponibles debido a errores",
|
||||
"IndexerValidationCloudFlareCaptchaExpired": "El token CAPTCHA de CloudFlare ha caducado, actualícelo.",
|
||||
@@ -1098,7 +1098,7 @@
|
||||
"IndexerSettingsRssUrlHelpText": "Introduzca la URL de un canal RSS compatible con {indexer}",
|
||||
"IndexerStatusUnavailableHealthCheckMessage": "Indexadores no disponibles debido a errores: {indexerNames}",
|
||||
"IndexerHDBitsSettingsMediums": "Medios",
|
||||
"IndexerSettingsSeedTimeHelpText": "El tiempo que un torrent debería ser sembrado antes de parar, vacío usa el predeterminado del cliente de descarga",
|
||||
"IndexerSettingsSeedTimeHelpText": "El tiempo que un torrent debería ser sembrado antes de detenerse, dejar vacío utiliza el valor predeterminado del cliente de descarga",
|
||||
"IndexerValidationCloudFlareCaptchaRequired": "Sitio protegido por CloudFlare CAPTCHA. Se requiere un token CAPTCHA válido.",
|
||||
"NotificationsEmailSettingsUseEncryption": "Usar Cifrado",
|
||||
"LastDuration": "Última Duración",
|
||||
@@ -1706,7 +1706,7 @@
|
||||
"UpgradeUntil": "Actualizar hasta",
|
||||
"UpdaterLogFiles": "Actualizador de archivos de registro",
|
||||
"UseSeasonFolder": "Usar carpeta de temporada",
|
||||
"UseHardlinksInsteadOfCopy": "Utilizar enlaces directos en lugar de copiar",
|
||||
"UseHardlinksInsteadOfCopy": "Utilizar enlaces físicos en lugar de copiar",
|
||||
"View": "Vista",
|
||||
"VisitTheWikiForMoreDetails": "Visita la wiki para más detalles: ",
|
||||
"WaitingToProcess": "Esperar al proceso",
|
||||
@@ -1875,7 +1875,7 @@
|
||||
"SmartReplace": "Reemplazo inteligente",
|
||||
"SupportedDownloadClientsMoreInfo": "Para más información en los clientes de descarga individuales, haz clic en los botones de más información.",
|
||||
"SupportedImportListsMoreInfo": "Para más información de los listas de importación individuales, haz clic en los botones de más información.",
|
||||
"TorrentBlackholeSaveMagnetFilesReadOnlyHelpText": "En lugar de mover archivos esto indicará a {appName} que copie o enlace (dependiendo de los ajustes/configuración del sistema)",
|
||||
"TorrentBlackholeSaveMagnetFilesReadOnlyHelpText": "En lugar de mover archivos, esto indicará a {appName} que copie o haga un enlace físico (dependiendo de los ajustes/configuración del sistema)",
|
||||
"TorrentDelay": "Retraso de torrent",
|
||||
"ToggleMonitoredToUnmonitored": "Monitorizado, haz clic para dejar de monitorizar",
|
||||
"TorrentBlackholeSaveMagnetFilesHelpText": "Guarda el enlace magnet si no hay ningún archivo .torrent disponible (útil solo si el cliente de descarga soporta magnets guardados en un archivo)",
|
||||
@@ -2170,5 +2170,19 @@
|
||||
"UserRejectedExtensions": "Extensiones adicionales de archivo rechazadas",
|
||||
"DownloadClientQbittorrentSettingsAddSeriesTagsHelpText": "Añade etiquetas de series a los nuevos torrents añadidos al cliente de descarga (qBittorrent 4.1.0+)",
|
||||
"UserRejectedExtensionsTextsExamples": "Ejemplos: '.ext, .xyz' o 'ext,xyz'",
|
||||
"UserRejectedExtensionsHelpText": "Lista de extensiones de archivo a fallar separadas por coma (Descargas fallidas también necesita ser activado por indexador)"
|
||||
"UserRejectedExtensionsHelpText": "Lista de extensiones de archivo a fallar separadas por coma (Descargas fallidas también necesita ser activado por indexador)",
|
||||
"IndexerSettingsSeasonPackSeedGoalUseStandardGoals": "Usar objetivos estándar",
|
||||
"IndexerSettingsSeasonPackSeedGoalUseSeasonPackGoals": "Usar objetivos de pack de temporada",
|
||||
"IndexerSettingsSeasonPackSeedGoalHelpText": "Elige si usar diferentes objetivos de sembrado para packs de temporada",
|
||||
"IndexerSettingsSeasonPackSeedRatio": "Ratio de siembra del pack de temporada",
|
||||
"RemoveRootFolderWithSeriesMessageText": "¿Estás seguro que quieres eliminar la carpeta raíz '{path}'? Los archivos y carpetas no serán borrados del disco, y las series en esta carpeta raíz no serán eliminadas de {appName}.",
|
||||
"IndexerSettingsSeasonPackSeedGoal": "Objetivo de sembrado para packs de temporada",
|
||||
"DownloadClientTriblerSettingsApiKeyHelpText": "[api].key de triblerd.conf",
|
||||
"DownloadClientTriblerSettingsDirectoryHelpText": "Localización opcional en la que poner las descargas, dejar en blanco para usar la localización predeterminada de Tribler",
|
||||
"IndexerSettingsSeasonPackSeedRatioHelpText": "El ratio que un torrent de pack de temporada debería alcanzar antes de detenerse, si se deja vacío usa el valor predeterminado del cliente de descarga. El ratio debería ser al menos 1.0 y seguir las reglas de los indexadores",
|
||||
"DownloadClientTriblerSettingsAnonymityLevel": "Nivel de anonimato",
|
||||
"DownloadClientTriblerSettingsAnonymityLevelHelpText": "Número de proxies a usar cuando se descarga contenido. Establecer a 0 para deshabilitarlo. Los proxies reducen la velocidad de descarga/subida. Ver {url}",
|
||||
"DownloadClientTriblerProviderMessage": "La integración con Tribler es altamente experimental. Probado con {clientName} versión {clientVersionRange}.",
|
||||
"DownloadClientTriblerSettingsSafeSeedingHelpText": "Cuando se habilita, solo se siembra a través de los proxies.",
|
||||
"DownloadClientTriblerSettingsSafeSeeding": "Sembrado seguro"
|
||||
}
|
||||
|
||||
@@ -1191,7 +1191,7 @@
|
||||
"PackageVersion": "Version du paquet",
|
||||
"PackageVersionInfo": "{packageVersion} par {packageAuthor}",
|
||||
"QuickSearch": "Recherche rapide",
|
||||
"ReleaseRejected": "Libération rejetée",
|
||||
"ReleaseRejected": "Version rejetée",
|
||||
"ReleaseSceneIndicatorAssumingScene": "En supposant la numérotation des scènes.",
|
||||
"ReleaseSceneIndicatorAssumingTvdb": "En supposant la numérotation TVDB.",
|
||||
"ReleaseSceneIndicatorMappedNotRequested": "L'épisode mappé n'a pas été demandé dans cette recherche.",
|
||||
@@ -2164,5 +2164,25 @@
|
||||
"UserRejectedExtensionsTextsExamples": "Examples : '.ext, .xyz' or 'ext,xyz'",
|
||||
"Warning": "Avertissement",
|
||||
"QualityDefinitionsSizeNotice": "Les restrictions de taille sont maintenant dans les profils de qualité",
|
||||
"UserInvokedSearch": "Recherche invoquée par l’utilisateur"
|
||||
"UserInvokedSearch": "Recherche invoquée par l’utilisateur",
|
||||
"DownloadClientTriblerSettingsAnonymityLevelHelpText": "Nombre de proxy à utiliser pour télécharger. Pour désactiver, mettre à 0. Les proxy réduisent la vitesse d'envoi et de réception. Voir {url}",
|
||||
"DownloadClientTriblerSettingsDirectoryHelpText": "Emplacement optionnel où mettre les téléchargements, laisser vide pour utiliser l'emplacement Tribler par défaut",
|
||||
"CloneImportList": "Cloner la liste d'importation",
|
||||
"IndexerSettingsSeasonPackSeedGoal": "Objective de partage pour les packs de saison",
|
||||
"IndexerSettingsSeasonPackSeedGoalHelpText": "Sélectionné si des objectifs différent devraient être utilisés pour les packs de saison",
|
||||
"IndexerSettingsSeasonPackSeedGoalUseStandardGoals": "Utiliser les objectifs standards",
|
||||
"IndexerSettingsSeasonPackSeedGoalUseSeasonPackGoals": "Utiliser les objectifs pour les packs de saison",
|
||||
"IndexerSettingsSeasonPackSeedRatio": "Ratio de partage pour les packs de saison",
|
||||
"MediaInfoFootNote2": "MediaInfo AudioLanguages exclue l’anglais s’il s’agit de la seule langue. Utiliser MediaInfo AudioLanguagesAll pour inclure ceux seulement en anglais",
|
||||
"NotificationsAppriseSettingsIncludePosterHelpText": "Inclure le poster dans le message",
|
||||
"ReleasePush": "Délai de version",
|
||||
"ReleaseSource": "Source de version",
|
||||
"RemoveRootFolderWithSeriesMessageText": "Êtes-vous sûr de vouloir supprimer le dossier racine '{path}' ? Les fichiers et les dossiers ne seront pas supprimés du disque, et les séries dans ce dossier racine ne seront pas supprimées de {appName}.",
|
||||
"IndexerSettingsSeasonPackSeedRatioHelpText": "Le ratio qu’un torrent de pack de saison devrait atteindre avant d’arrêter. Laisser à vide utilise le default du client de téléchargement. Le ratio devrait être au moins 1.0 et sur les règles de l’indexeur",
|
||||
"DownloadClientTriblerSettingsAnonymityLevel": "Niveau d’anonymat",
|
||||
"DownloadClientTriblerSettingsApiKeyHelpText": "[api].key de triblerd.conf",
|
||||
"DownloadClientTriblerSettingsSafeSeeding": "Partage protégé",
|
||||
"DownloadClientTriblerSettingsSafeSeedingHelpText": "Lorsque activé, seulement partager via un proxy.",
|
||||
"DownloadClientTriblerProviderMessage": "L’intégration avec tribler est hautement expérimental. Tester sur {clientName} version {clientVersionRange}.",
|
||||
"NotificationsAppriseSettingsIncludePoster": "Inclure le poster"
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
"AddDelayProfile": "Voeg vertragingsprofiel toe",
|
||||
"AddCustomFormatError": "Kan geen nieuw aangepast formaat toevoegen. Probeer het opnieuw.",
|
||||
"AddDownloadClientError": "Kan geen nieuwe downloadclient toevoegen. Probeer het opnieuw.",
|
||||
"AddDownloadClient": "Download Client Toevoegen",
|
||||
"AddDownloadClient": "Downloadprogramma Toevoegen",
|
||||
"AddIndexerError": "Kon geen nieuwe indexeerder toevoegen, Probeer het opnieuw.",
|
||||
"AddList": "Lijst Toevoegen",
|
||||
"AddListError": "Kon geen nieuwe lijst toevoegen, probeer het opnieuw.",
|
||||
@@ -266,5 +266,36 @@
|
||||
"CertificateValidationHelpText": "Verander hoe strikt HTTPS-certificaatvalidatie is. Verander dit niet als je de risico's niet begrijpt.",
|
||||
"ChmodFolderHelpText": "Octaal, toegepast tijdens importeren/hernoemen van mediamappen en -bestanden (zonder uitvoeringsbits)",
|
||||
"CalendarFeed": "{appName} kalenderfeed",
|
||||
"BypassDelayIfHighestQualityHelpText": "Omzeil uitstel wanneer de uitgave de hoogst mogelijke kwaliteit heeft uit het kwaliteitsprofiel van het geprefereerde protocol"
|
||||
"BypassDelayIfHighestQualityHelpText": "Omzeil uitstel wanneer de uitgave de hoogst mogelijke kwaliteit heeft uit het kwaliteitsprofiel van het geprefereerde protocol",
|
||||
"DownloadClientValidationSslConnectFailure": "Kan niet verbinden via SSL",
|
||||
"DownloadClientValidationGroupMissing": "Groep bestaat niet",
|
||||
"DownloadClientValidationUnknownException": "Onbekende fout: {exception}",
|
||||
"DownloadClientValidationVerifySsl": "Verifieer SSL instellingen",
|
||||
"DownloadClients": "Downloadprogramma's",
|
||||
"DownloadClientTriblerSettingsAnonymityLevelHelpText": "Aantal proxies om te gebruiken bij het downloaden van content. Zet dit op 0 om uit te schakelen. Proxies vertragen download- en uploadsnelheid. Zie {url}",
|
||||
"DownloadClientTriblerProviderMessage": "De Tribler-integratie is heel experimenteel. Getest met {clientName} versie {clientVersionRange}.",
|
||||
"DownloadClientValidationAuthenticationFailureDetail": "Gelieve uw gebruikersnaam en wachtwoord te verifiëren. Verifieer ook of de host waar {appName} op draait niet geblokkeerd is voor toegang tot {clientName} door Whitelist limitaties in de {clientName} instellingen.",
|
||||
"DownloadClientValidationSslConnectFailureDetail": "{appName} kan niet verbinden met {clientName} via SSL. Dit probleem kan computergerelateerd zijn. Probeer alstublieft om {appName} en {clientName} te configureren om geen SSL te gebruiken.",
|
||||
"DownloadClientValidationAuthenticationFailure": "Authenticatiefout",
|
||||
"DownloadClientTriblerSettingsApiKeyHelpText": "[api].key uit triblerd.conf",
|
||||
"DownloadClientTriblerSettingsDirectoryHelpText": "Optionele locatie om downloads in te plaatsen, laat leeg om de standaard Tribler locatie te gebruiken",
|
||||
"DownloadClientTriblerSettingsSafeSeeding": "Veilig Seeden",
|
||||
"DownloadClientTriblerSettingsSafeSeedingHelpText": "Wanneer ingeschakeld alleen maar seeden via proxies.",
|
||||
"DownloadClientUTorrentTorrentStateError": "uTorrent rapporteert een fout",
|
||||
"DownloadClientUnavailable": "Download Client niet beschikbaar",
|
||||
"DownloadClientValidationApiKeyIncorrect": "API-sleutel Incorrect",
|
||||
"DownloadClientValidationApiKeyRequired": "API-sleutel Nodig",
|
||||
"DownloadClientValidationCategoryMissing": "Categorie bestaat niet",
|
||||
"DownloadClientValidationCategoryMissingDetail": "De ingevoerde categorie bestaat niet in {clientName}. Voeg deze eerst toe in {clientName}.",
|
||||
"DownloadClientValidationErrorVersion": "{clientName} versie moet tenminste {requiredVersion} zijn. Gerapporteerde versie is {reportedVersion}",
|
||||
"DownloadClientValidationGroupMissingDetail": "De ingevoerde groep bestaat niet in {clientName}. Voeg deze eerst toe in {clientName}.",
|
||||
"DownloadClientValidationTestNzbs": "Kon de lijst van NZBs niet verkrijgen: {exceptionMessage}",
|
||||
"DownloadClientValidationTestTorrents": "Kon de lijst van torrents niet verkrijgen: {exceptionMessage}",
|
||||
"DownloadClientValidationUnableToConnect": "Kon niet verbinden met {clientName}",
|
||||
"DownloadClientValidationUnableToConnectDetail": "Gelieve de hostnaam en poort te verifiëren.",
|
||||
"DownloadClientValidationVerifySslDetail": "Gelieve uw SSL-configuratie te verifiëren in zowel {clientName} als {appName}",
|
||||
"DownloadClientVuzeValidationErrorVersion": "Protocolversie niet ondersteund, gebruik Vuze 5.0.0.0 of hoger met de Vuze Web Remote plugin.",
|
||||
"DownloadClientsLoadError": "Kan downloadprogramma's niet laden",
|
||||
"GeneralSettingsLoadError": "Algemene instellingen konden niet worden geladen",
|
||||
"GeneralSettingsSummary": "Poort, SSL, gebruikersnaam/wachtwoord, proxy, analytics en updates"
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"Added": "Adicionado",
|
||||
"ApiKeyValidationHealthCheckMessage": "Atualize sua chave de API para ter pelo menos {length} caracteres. Você pode fazer isso através das configurações ou do arquivo de configuração",
|
||||
"RemoveCompletedDownloads": "Remover downloads concluídos",
|
||||
"AppDataLocationHealthCheckMessage": "Não será possível atualizar para evitar a exclusão de AppData na Atualização",
|
||||
"AppDataLocationHealthCheckMessage": "A atualização não será possível para impedir a exclusão de AppData na Atualização",
|
||||
"DownloadClientCheckUnableToCommunicateWithHealthCheckMessage": "Não é possível se comunicar com {downloadClientName}. {errorMessage}",
|
||||
"DownloadClientRootFolderHealthCheckMessage": "O cliente de download {downloadClientName} coloca os downloads na pasta raiz {rootFolderPath}. Você não deve baixar para uma pasta raiz.",
|
||||
"DownloadClientSortingHealthCheckMessage": "O cliente de download {downloadClientName} tem classificação {sortingMode} habilitada para a categoria do {appName}. Você deve desabilitar essa classificação em seu cliente de download para evitar problemas de importação.",
|
||||
@@ -1632,7 +1632,7 @@
|
||||
"IndexerSettingsMinimumSeedersHelpText": "Quantidade mínima de semeadores necessária.",
|
||||
"IndexerSettingsPasskey": "Chave de acesso",
|
||||
"IndexerSettingsRssUrl": "URL do RSS",
|
||||
"IndexerSettingsSeasonPackSeedTime": "Tempo de semeadura para pacotes de temporada",
|
||||
"IndexerSettingsSeasonPackSeedTime": "Tempo de Semeadura para Pacotes de Temporada",
|
||||
"IndexerSettingsSeedRatio": "Proporção de semeadura",
|
||||
"IndexerSettingsSeedTime": "Tempo de semeadura",
|
||||
"IndexerSettingsSeedTimeHelpText": "Quanto tempo um torrent deve ser semeado antes de parar, deixe vazio para usar o padrão do cliente de download",
|
||||
@@ -2170,5 +2170,19 @@
|
||||
"UserRejectedExtensionsHelpText": "Lista separada por vírgulas de extensões de arquivos para falhar (Falha em downloads também precisa ser habilitado por indexador)",
|
||||
"UserRejectedExtensionsTextsExamples": "Exemplos: '.ext, .xyz' or 'ext,xyz'",
|
||||
"DownloadClientQbittorrentSettingsAddSeriesTagsHelpText": "Adicionar etiquetas das séries a novos torrents adicionados ao cliente de download (qBittorrent 4.1.0+)",
|
||||
"DownloadClientQbittorrentSettingsAddSeriesTags": "Adicionar Etiquetas das Séries"
|
||||
"DownloadClientQbittorrentSettingsAddSeriesTags": "Adicionar Etiquetas das Séries",
|
||||
"IndexerSettingsSeasonPackSeedGoal": "Meta de Semeadura para Pacotes de Temporada",
|
||||
"IndexerSettingsSeasonPackSeedGoalUseStandardGoals": "Usar Metas Padrões",
|
||||
"IndexerSettingsSeasonPackSeedGoalUseSeasonPackGoals": "Usar as Metas de Pacote de Temporada",
|
||||
"IndexerSettingsSeasonPackSeedRatio": "Proporção de Semeadura do Pacote de Temporada",
|
||||
"IndexerSettingsSeasonPackSeedRatioHelpText": "A proporção que um torrent de pacote de temporada deve atingir antes de parar, vazio usa o padrão do cliente de download. A proporção deve ser de pelo menos 1,0 e seguir as regras dos indexadores",
|
||||
"IndexerSettingsSeasonPackSeedGoalHelpText": "Escolha se deseja usar metas de semeadura diferentes para pacotes de temporada",
|
||||
"RemoveRootFolderWithSeriesMessageText": "Tem certeza de que deseja remover a pasta raiz '{path}'? Arquivos e pastas não serão excluídos do disco e as séries nesta pasta raiz não serão removidas de {appName}.",
|
||||
"DownloadClientTriblerSettingsAnonymityLevel": "Nível de anonimato",
|
||||
"DownloadClientTriblerSettingsAnonymityLevelHelpText": "Número de proxies a serem usados ao baixar conteúdo. Para desativar, defina como 0. Os proxies reduzem a velocidade de download/upload. Veja {url}",
|
||||
"DownloadClientTriblerSettingsApiKeyHelpText": "[API]. chave de triblerd.conf",
|
||||
"DownloadClientTriblerSettingsDirectoryHelpText": "Local opcional para colocar downloads, deixe em branco para usar o local padrão do Tribler",
|
||||
"DownloadClientTriblerSettingsSafeSeeding": "Semeadura Segura",
|
||||
"DownloadClientTriblerSettingsSafeSeedingHelpText": "Quando ativado, apenas semeia por meio de proxies.",
|
||||
"DownloadClientTriblerProviderMessage": "A integração tribler é altamente experimental. Testado em {clientName} versão {clientVersionRange}."
|
||||
}
|
||||
|
||||
@@ -1371,7 +1371,7 @@
|
||||
"Hostname": "Имя хоста",
|
||||
"Host": "Хост",
|
||||
"IndexerSettingsRssUrlHelpText": "Введите URL-адрес RSS-канала, совместимого с {indexer}",
|
||||
"IndexerSettingsSeasonPackSeedTimeHelpText": "Время, когда торрент сезонного пакета должен быть на раздаче перед остановкой, при пустом значении используется значение по умолчанию клиента загрузки",
|
||||
"IndexerSettingsSeasonPackSeedTimeHelpText": "Время, когда торрент полного сезона должен быть на раздаче, перед остановкой, при пустом значении используется значение загрузчика по умолчанию",
|
||||
"IndexerSettingsSeedRatioHelpText": "Рейтинг, которого должен достичь торрент перед остановкой, пусто — используется значение по умолчанию клиента загрузки. Рейтинг должен быть не менее 1,0 и соответствовать правилам индексаторов",
|
||||
"IndexerHDBitsSettingsMediumsHelpText": "Если не указано, используются все параметры.",
|
||||
"IndexerSettingsApiUrlHelpText": "Не меняйте это, если вы не знаете, что делаете. Поскольку ваш ключ API будет отправлен на этот хост.",
|
||||
@@ -1681,7 +1681,7 @@
|
||||
"ImportListsPlexSettingsWatchlistRSSName": "Список наблюдения Plex RSS",
|
||||
"IndexerSettingsAnimeCategoriesHelpText": "Выпадающий список, оставьте пустым, чтобы отключить аниме",
|
||||
"IndexerSettingsMultiLanguageRelease": "Несколько языков",
|
||||
"IndexerSettingsSeasonPackSeedTime": "Время сидирования сезон-пака",
|
||||
"IndexerSettingsSeasonPackSeedTime": "Время сидирования полного сезона",
|
||||
"IndexerValidationUnableToConnectResolutionFailure": "Невозможно подключиться к индексатору. Проверьте подключение к серверу индексатора и DNS. {exceptionMessage}.",
|
||||
"IndexerValidationUnableToConnectHttpError": "Невозможно подключиться к индексатору. Проверьте настройки DNS и убедитесь, что IPv6 работает или отключен. {exceptionMessage}.",
|
||||
"IndexerTagSeriesHelpText": "Используйте этот индексатор только для сериалов, имеющих хотя бы один соответствующий тег. Оставьте поле пустым, чтобы использовать его для всех.",
|
||||
@@ -2170,5 +2170,19 @@
|
||||
"UserRejectedExtensionsTextsExamples": "Примеры: '.ext, .xyz' или 'ext,xyz'",
|
||||
"DownloadClientQbittorrentSettingsAddSeriesTags": "Добавлять теги сериалов",
|
||||
"DownloadClientQbittorrentSettingsAddSeriesTagsHelpText": "Добавлять теги сериалов к новым торрентам, добавляемым в загрузчик (qBittorrent 4.1.0+)",
|
||||
"UserRejectedExtensionsHelpText": "Список запрещенных расширений файлов, разделенных запятой (так же нужно включить настройку Считать загрузки неуспешными в настройках индексаторов)"
|
||||
"UserRejectedExtensionsHelpText": "Список запрещенных расширений файлов, разделенных запятой (так же нужно включить настройку Считать загрузки неуспешными в настройках индексаторов)",
|
||||
"IndexerSettingsSeasonPackSeedGoal": "Целевая раздача для полных сезонов",
|
||||
"IndexerSettingsSeasonPackSeedGoalHelpText": "Следует ли использовать другое значение целевой раздачи для полных сезонов",
|
||||
"IndexerSettingsSeasonPackSeedGoalUseStandardGoals": "Использовать стандартные значения",
|
||||
"IndexerSettingsSeasonPackSeedRatio": "Соотношение раздачи для полных сезонов",
|
||||
"IndexerSettingsSeasonPackSeedGoalUseSeasonPackGoals": "Использовать целевые значения для полных сезонов",
|
||||
"IndexerSettingsSeasonPackSeedRatioHelpText": "Соотношение, которое полные сезоны должны достигнуть перед остановкой. Пустое значение приведёт к использованию значения загрузчика по умолчанию. Соотношение должно быть не менее 1.0 и соответствовать правилам индексаторов",
|
||||
"RemoveRootFolderWithSeriesMessageText": "Вы уверены, что хотите удалить корневой каталог '{path}'? Файлов и папки не будут удалены с диска, а сериалы в этом корневом каталоге останутся в {appName}.",
|
||||
"DownloadClientTriblerSettingsAnonymityLevel": "Уровень анонимности",
|
||||
"DownloadClientTriblerSettingsAnonymityLevelHelpText": "Количество используемых прокси-серверов для скачивания контента. Чтобы выключить, укажите 0. Прокси-серверы уменьшают скорость скачивания/загрузки. Доп. инфо: {url}",
|
||||
"DownloadClientTriblerSettingsApiKeyHelpText": "[api].key из triblerd.conf",
|
||||
"DownloadClientTriblerSettingsDirectoryHelpText": "Не обязательный путь для сохранения загрузок. Оставьте пустым, чтобы использовать путь Tribler по умолчанию",
|
||||
"DownloadClientTriblerSettingsSafeSeeding": "Безопасная раздача",
|
||||
"DownloadClientTriblerSettingsSafeSeedingHelpText": "Если включено, раздача будет вестись только через прокси-серверы.",
|
||||
"DownloadClientTriblerProviderMessage": "Интеграция с Tribler находится в ранней экспериментальной стадии. Тестировалось на {clientName} версии {clientVersionRange}."
|
||||
}
|
||||
|
||||
@@ -1780,14 +1780,14 @@
|
||||
"ImportListsTraktSettingsWatchListSortingHelpText": "Liste Türü İzlenen ise, listeyi sıralamak için sırayı seçin",
|
||||
"IndexerSearchNoAutomaticHealthCheckMessage": "Otomatik Arama etkinleştirildiğinde hiçbir indeksleyici kullanılamaz, {appName} herhangi bir otomatik arama sonucu sağlamayacaktır",
|
||||
"IndexerSettingsApiUrlHelpText": "Ne yaptığınızı bilmiyorsanız bunu değiştirmeyin. API anahtarınız ana sunucuya gönderilecektir.",
|
||||
"IndexerSettingsSeasonPackSeedTimeHelpText": "Bir sezon paketi torrentinin durdurulmadan önce başlatılması gereken süre, boş bırakıldığında indirme istemcisinin varsayılanı kullanılır",
|
||||
"IndexerSettingsSeasonPackSeedTimeHelpText": "Bir sezon paketi torrentinin durmadan önce paylaşılması gereken süre. Boş bırakılırsa indirme istemcisinin varsayılanı kullanılır",
|
||||
"IndexerValidationCloudFlareCaptchaRequired": "Site CloudFlare CAPTCHA tarafından korunmaktadır. Geçerli CAPTCHA belirteci gereklidir.",
|
||||
"IndexerValidationUnableToConnectServerUnavailable": "İndeksleyiciye bağlanılamıyor, indeksleyicinin sunucusu kullanılamıyor. Daha sonra tekrar deneyin. {exceptionMessage}.",
|
||||
"ImportListsTraktSettingsUserListUsernameHelpText": "İçe aktarılacak Liste için Kullanıcı Adı (Yetkili Kullanıcı için boş bırakın)",
|
||||
"ImportListsTraktSettingsYearsSeriesHelpText": "Diziyi yıla veya yıl aralığına göre filtreleyin",
|
||||
"IndexerHDBitsSettingsMediums": "Ortamlar",
|
||||
"IndexerSearchNoAvailableIndexersHealthCheckMessage": "Son zamanlardaki indeksleyici hataları nedeniyle tüm arama yeteneğine sahip indeksleyiciler geçici olarak kullanılamıyor",
|
||||
"IndexerSettingsSeasonPackSeedTime": "Sezon Paketi Seed Süresi",
|
||||
"IndexerSettingsSeasonPackSeedTime": "Sezon Paketi Gönderme (Seed) Süresi",
|
||||
"IndexerValidationJackettAllNotSupportedHelpText": "Jackett'in tüm uç noktaları desteklenmiyor, lütfen indeksleyicileri tek tek ekleyin",
|
||||
"IndexerValidationNoRssFeedQueryAvailable": "RSS besleme sorgusu mevcut değil. Bu, indeksleyici veya indeksleyici kategori ayarlarınızdan kaynaklı bir sorun olabilir.",
|
||||
"IndexerValidationUnableToConnectResolutionFailure": "İndeksleyiciye bağlanılamıyor bağlantı hatası. İndeksleyicinin sunucusuna ve DNS'ine olan bağlantınızı kontrol edin. {exceptionMessage}.",
|
||||
@@ -2170,5 +2170,19 @@
|
||||
"MonitorEpisodesModalInfo": "Bu ayar, bir dizide hangi bölüm veya sezonların takip edileceğini kontrol eder. \"Hiçbiri\" seçilirse, dizi takip edilmeyecektir",
|
||||
"UserRejectedExtensions": "Ek Olarak Reddedilen Dosya Uzantıları",
|
||||
"UserRejectedExtensionsHelpText": "Başarısız sayılacak dosya uzantılarını virgülle ayırarak girin (Ayrıca, her dizinleyici için \"İndirmeleri Başarısız Say\" seçeneği etkin olmalıdır)",
|
||||
"EpisodeMonitoring": "Bölüm Takibi"
|
||||
"EpisodeMonitoring": "Bölüm Takibi",
|
||||
"IndexerSettingsSeasonPackSeedGoalUseStandardGoals": "Standart Hedefleri Kullanın",
|
||||
"IndexerSettingsSeasonPackSeedGoalUseSeasonPackGoals": "Sezon Paketi Hedeflerini Kullan",
|
||||
"IndexerSettingsSeasonPackSeedRatio": "Sezon Paketi Paylaşım(Seed) Oranı",
|
||||
"RemoveRootFolderWithSeriesMessageText": "Kök klasörü silmek istediğinizden emin misiniz? ‘{path}’ klasörü silinecek. Diskten dosya ve klasörler silinmeyecek, ve bu kök klasördeki diziler {appName} içinden kaldırılmayacak.",
|
||||
"IndexerSettingsSeasonPackSeedGoal": "Sezon Paketleri için Paylaşım(seed) Hedefi",
|
||||
"IndexerSettingsSeasonPackSeedGoalHelpText": "Sezon paketleri için farklı paylaşım hedefleri kullanıp kullanmamayı seçin",
|
||||
"IndexerSettingsSeasonPackSeedRatioHelpText": "Bir sezon paketi torrentinin durmadan önce ulaşması gereken oran. Boş bırakılırsa indirme istemcisinin varsayılanı kullanılır. Oran en az 1.0 olmalı ve indeksleyicinin kurallarına uymalıdır",
|
||||
"DownloadClientTriblerSettingsAnonymityLevel": "Anonimlik seviyesi",
|
||||
"DownloadClientTriblerSettingsAnonymityLevelHelpText": "İçerik indirirken kullanılacak proxy sayısı. Devre dışı bırakmak için 0 olarak ayarlayın. Proxy’ler indirme/yükleme hızını azaltır. Bkz. {url}",
|
||||
"DownloadClientTriblerSettingsApiKeyHelpText": "triblerd.conf dosyasından [api].key",
|
||||
"DownloadClientTriblerSettingsDirectoryHelpText": "İsteğe bağlı olarak indirmelerin kaydedileceği konum. Varsayılan Tribler konumunu kullanmak için boş bırakın",
|
||||
"DownloadClientTriblerSettingsSafeSeeding": "Güvenli Paylaşım",
|
||||
"DownloadClientTriblerSettingsSafeSeedingHelpText": "Etkinleştirildiğinde, yalnızca proxy’ler üzerinden paylaşım(seed) yapar.",
|
||||
"DownloadClientTriblerProviderMessage": "Tribler entegrasyonu oldukça deneyseldir. {clientName} istemcisinin {clientVersionRange} sürüm aralığında test edilmiştir."
|
||||
}
|
||||
|
||||
@@ -61,7 +61,8 @@ namespace NzbDrone.Core.Parser
|
||||
new IsoLanguage("uz", "", "uzb", Language.Uzbek),
|
||||
new IsoLanguage("ms", "", "msa", Language.Malay),
|
||||
new IsoLanguage("ur", "", "urd", Language.Urdu),
|
||||
new IsoLanguage("rm", "", "roh", Language.Romansh)
|
||||
new IsoLanguage("rm", "", "roh", Language.Romansh),
|
||||
new IsoLanguage("ka", "", "kat", Language.Georgian)
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, Language> AlternateIsoCodeMappings = new Dictionary<string, Language>
|
||||
|
||||
@@ -20,7 +20,7 @@ namespace NzbDrone.Core.Parser
|
||||
new RegexReplace(@".*?[_. ](S\d{2}(?:E\d{2,4})*[_. ].*)", "$1", RegexOptions.Compiled | RegexOptions.IgnoreCase)
|
||||
};
|
||||
|
||||
private static readonly Regex LanguageRegex = new Regex(@"(?<english>\b(?:ing|eng)\b)|(?<italian>\b(?:ita|italian)\b)|(?<german>(?:swiss)?german\b|videomann|ger[. ]dub|\bger\b)|(?<flemish>flemish)|(?<greek>greek)|(?<french>(?:\W|_|\b)(?:FR|VF|VF2|VFF|VFI|VFQ|TRUEFRENCH|FRENCH|FRE|FRA)(?:\W|_|\b))|(?<russian>\b(?:rus|ru)\b)|(?<hungarian>\b(?:HUNDUB|HUN)\b)|(?<hebrew>\bHebDub\b)|(?<polish>\b(?:PL\W?DUB|DUB\W?PL|LEK\W?PL|PL\W?LEK)\b)|(?<chinese>\[(?:CH[ST]|BIG5|GB)\]|简|繁|字幕)|(?<bulgarian>\bbgaudio\b)|(?<spanish>\b(?:español|castellano|esp|spa(?!\(Latino\)))\b)|(?<ukrainian>\b(?:\dx?)?(?:ukr))|(?<thai>\b(?:THAI)\b)|(?<romanian>\b(?:RoDubbed|ROMANIAN)\b)|(?<catalan>[-,. ]cat[. ](?:DD|subs)|\b(?:catalan|catalán)\b)|(?<latvian>\b(?:lat|lav|lv)\b)|(?<turkish>\b(?:tur)\b)|(?<urdu>\burdu\b)|(?<romansh>\b(?:romansh|rumantsch|romansch)\b)|(?<japanese>\(JA\))|(?<original>\b(?:orig|original)\b)",
|
||||
private static readonly Regex LanguageRegex = new Regex(@"(?<english>\b(?:ing|eng)\b)|(?<italian>\b(?:ita|italian)\b)|(?<german>(?:swiss)?german\b|videomann|ger[. ]dub|\bger\b)|(?<flemish>flemish)|(?<greek>greek)|(?<french>(?:\W|_|\b)(?:FR|VF|VF2|VFF|VFI|VFQ|TRUEFRENCH|FRENCH|FRE|FRA)(?:\W|_|\b))|(?<russian>\b(?:rus|ru)\b)|(?<hungarian>\b(?:HUNDUB|HUN)\b)|(?<hebrew>\bHebDub\b)|(?<polish>\b(?:PL\W?DUB|DUB\W?PL|LEK\W?PL|PL\W?LEK)\b)|(?<chinese>\[(?:CH[ST]|BIG5|GB)\]|简|繁|字幕)|(?<bulgarian>\bbgaudio\b)|(?<spanish>\b(?:español|castellano|esp|spa(?!\(Latino\)))\b)|(?<ukrainian>\b(?:\dx?)?(?:ukr))|(?<thai>\b(?:THAI)\b)|(?<romanian>\b(?:RoDubbed|ROMANIAN)\b)|(?<catalan>[-,. ]cat[. ](?:DD|subs)|\b(?:catalan|catalán)\b)|(?<latvian>\b(?:lat|lav|lv)\b)|(?<turkish>\b(?:tur)\b)|(?<urdu>\burdu\b)|(?<romansh>\b(?:romansh|rumantsch|romansch)\b)|(?<georgian>\b(?:geo|ka|kat|georgian)\b)|(?<japanese>\(JA\))|(?<original>\b(?:orig|original)\b)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex CaseSensitiveLanguageRegex = new Regex(@"(?:(?i)(?<!SUB[\W|_|^]))(?:(?<lithuanian>\bLT\b)|(?<czech>\bCZ\b)|(?<polish>\bPL\b)|(?<bulgarian>\bBG\b)|(?<slovak>\bSK\b)|(?<german>\bDE\b))(?:(?i)(?![\W|_|^]SUB))",
|
||||
@@ -496,6 +496,11 @@ namespace NzbDrone.Core.Parser
|
||||
languages.Add(Language.Romansh);
|
||||
}
|
||||
|
||||
if (match.Groups["georgian"].Success)
|
||||
{
|
||||
languages.Add(Language.Georgian);
|
||||
}
|
||||
|
||||
if (match.Groups["japanese"].Success)
|
||||
{
|
||||
languages.Add(Language.Japanese);
|
||||
|
||||
@@ -43,6 +43,11 @@ namespace NzbDrone.Core.Parser.Model
|
||||
/// <summary>
|
||||
/// The release is nuked
|
||||
/// </summary>
|
||||
Nuked = 128
|
||||
Nuked = 128,
|
||||
|
||||
/// <summary>
|
||||
/// The release contains subtitles
|
||||
/// </summary>
|
||||
Subtitles = 256
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NzbDrone.Common.Crypto;
|
||||
using NzbDrone.Core.Download.TrackedDownloads;
|
||||
using NzbDrone.Core.Languages;
|
||||
using NzbDrone.Core.Messaging.Events;
|
||||
using NzbDrone.Core.Qualities;
|
||||
using NzbDrone.Core.Tv;
|
||||
|
||||
#pragma warning disable CS0612
|
||||
namespace NzbDrone.Core.Queue
|
||||
{
|
||||
public interface IObsoleteQueueService
|
||||
{
|
||||
List<Queue> GetQueue();
|
||||
Queue Find(int id);
|
||||
void Remove(int id);
|
||||
}
|
||||
|
||||
public class ObsoleteQueueService : IObsoleteQueueService, IHandle<TrackedDownloadRefreshedEvent>
|
||||
{
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
private static List<Queue> _queue = new();
|
||||
|
||||
public ObsoleteQueueService(IEventAggregator eventAggregator)
|
||||
{
|
||||
_eventAggregator = eventAggregator;
|
||||
}
|
||||
|
||||
public List<Queue> GetQueue()
|
||||
{
|
||||
return _queue;
|
||||
}
|
||||
|
||||
public Queue Find(int id)
|
||||
{
|
||||
return _queue.SingleOrDefault(q => q.Id == id);
|
||||
}
|
||||
|
||||
public void Remove(int id)
|
||||
{
|
||||
_queue.Remove(Find(id));
|
||||
}
|
||||
|
||||
private IEnumerable<Queue> MapQueue(TrackedDownload trackedDownload)
|
||||
{
|
||||
if (trackedDownload.RemoteEpisode?.Episodes != null && trackedDownload.RemoteEpisode.Episodes.Any())
|
||||
{
|
||||
foreach (var episode in trackedDownload.RemoteEpisode.Episodes)
|
||||
{
|
||||
yield return MapQueueItem(trackedDownload, episode);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return MapQueueItem(trackedDownload, null);
|
||||
}
|
||||
}
|
||||
|
||||
private Queue MapQueueItem(TrackedDownload trackedDownload, Episode episode)
|
||||
{
|
||||
var queue = new Queue
|
||||
{
|
||||
Series = trackedDownload.RemoteEpisode?.Series,
|
||||
Episode = episode,
|
||||
Languages = trackedDownload.RemoteEpisode?.Languages ?? new List<Language> { Language.Unknown },
|
||||
Quality = trackedDownload.RemoteEpisode?.ParsedEpisodeInfo.Quality ?? new QualityModel(Quality.Unknown),
|
||||
Title = Parser.Parser.RemoveFileExtension(trackedDownload.DownloadItem.Title),
|
||||
Size = trackedDownload.DownloadItem.TotalSize,
|
||||
SizeLeft = trackedDownload.DownloadItem.RemainingSize,
|
||||
TimeLeft = trackedDownload.DownloadItem.RemainingTime,
|
||||
Status = Enum.TryParse(trackedDownload.DownloadItem.Status.ToString(), out QueueStatus outValue) ? outValue : QueueStatus.Unknown,
|
||||
TrackedDownloadStatus = trackedDownload.Status,
|
||||
TrackedDownloadState = trackedDownload.State,
|
||||
StatusMessages = trackedDownload.StatusMessages.ToList(),
|
||||
ErrorMessage = trackedDownload.DownloadItem.Message,
|
||||
RemoteEpisode = trackedDownload.RemoteEpisode,
|
||||
DownloadId = trackedDownload.DownloadItem.DownloadId,
|
||||
Protocol = trackedDownload.Protocol,
|
||||
DownloadClient = trackedDownload.DownloadItem.DownloadClientInfo.Name,
|
||||
Indexer = trackedDownload.Indexer,
|
||||
OutputPath = trackedDownload.DownloadItem.OutputPath.ToString(),
|
||||
Added = trackedDownload.Added,
|
||||
DownloadClientHasPostImportCategory = trackedDownload.DownloadItem.DownloadClientInfo.HasPostImportCategory
|
||||
};
|
||||
|
||||
queue.Id = HashConverter.GetHashInt31($"trackedDownload-{trackedDownload.DownloadClient}-{trackedDownload.DownloadItem.DownloadId}-ep{episode?.Id ?? 0}");
|
||||
|
||||
if (queue.TimeLeft.HasValue)
|
||||
{
|
||||
queue.EstimatedCompletionTime = DateTime.UtcNow.Add(queue.TimeLeft.Value);
|
||||
}
|
||||
|
||||
return queue;
|
||||
}
|
||||
|
||||
public void Handle(TrackedDownloadRefreshedEvent message)
|
||||
{
|
||||
_queue = message.TrackedDownloads
|
||||
.Where(t => t.IsTrackable)
|
||||
.OrderBy(c => c.DownloadItem.RemainingTime)
|
||||
.SelectMany(MapQueue)
|
||||
.ToList();
|
||||
|
||||
_eventAggregator.PublishEvent(new ObsoleteQueueUpdatedEvent());
|
||||
}
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0612
|
||||
@@ -1,8 +0,0 @@
|
||||
using NzbDrone.Common.Messaging;
|
||||
|
||||
namespace NzbDrone.Core.Queue
|
||||
{
|
||||
public class ObsoleteQueueUpdatedEvent : IEvent
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,7 @@ namespace NzbDrone.Core.Queue
|
||||
public class Queue : ModelBase
|
||||
{
|
||||
public Series Series { get; set; }
|
||||
|
||||
public int? SeasonNumber { get; set; }
|
||||
|
||||
[Obsolete]
|
||||
public Episode Episode { get; set; }
|
||||
|
||||
public List<Episode> Episodes { get; set; }
|
||||
public List<Language> Languages { get; set; }
|
||||
public QualityModel Quality { get; set; }
|
||||
public decimal Size { get; set; }
|
||||
|
||||
@@ -47,7 +47,10 @@ namespace NzbDrone.Core.Queue
|
||||
{
|
||||
if (trackedDownload.RemoteEpisode?.Episodes != null && trackedDownload.RemoteEpisode.Episodes.Any())
|
||||
{
|
||||
yield return MapQueueItem(trackedDownload, trackedDownload.RemoteEpisode.Episodes);
|
||||
foreach (var episode in trackedDownload.RemoteEpisode.Episodes)
|
||||
{
|
||||
yield return MapQueueItem(trackedDownload, episode);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -55,13 +58,12 @@ namespace NzbDrone.Core.Queue
|
||||
}
|
||||
}
|
||||
|
||||
private Queue MapQueueItem(TrackedDownload trackedDownload, List<Episode> episodes)
|
||||
private Queue MapQueueItem(TrackedDownload trackedDownload, Episode episode)
|
||||
{
|
||||
var queue = new Queue
|
||||
{
|
||||
Series = trackedDownload.RemoteEpisode?.Series,
|
||||
SeasonNumber = trackedDownload.RemoteEpisode?.MappedSeasonNumber,
|
||||
Episodes = episodes,
|
||||
Episode = episode,
|
||||
Languages = trackedDownload.RemoteEpisode?.Languages ?? new List<Language> { Language.Unknown },
|
||||
Quality = trackedDownload.RemoteEpisode?.ParsedEpisodeInfo.Quality ?? new QualityModel(Quality.Unknown),
|
||||
Title = FileExtensions.RemoveFileExtension(trackedDownload.DownloadItem.Title),
|
||||
@@ -83,7 +85,7 @@ namespace NzbDrone.Core.Queue
|
||||
DownloadClientHasPostImportCategory = trackedDownload.DownloadItem.DownloadClientInfo.HasPostImportCategory
|
||||
};
|
||||
|
||||
queue.Id = HashConverter.GetHashInt31($"trackedDownload-{trackedDownload.DownloadClient}-{trackedDownload.DownloadItem.DownloadId}");
|
||||
queue.Id = HashConverter.GetHashInt31($"trackedDownload-{trackedDownload.DownloadClient}-{trackedDownload.DownloadItem.DownloadId}-ep{episode?.Id ?? 0}");
|
||||
|
||||
if (queue.TimeLeft.HasValue)
|
||||
{
|
||||
|
||||
@@ -9,7 +9,5 @@ namespace NzbDrone.SignalR
|
||||
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public ModelAction Action { get; set; }
|
||||
|
||||
public int? Version { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,14 +21,13 @@ using Sonarr.Http.Extensions;
|
||||
using Sonarr.Http.REST;
|
||||
using Sonarr.Http.REST.Attributes;
|
||||
|
||||
#pragma warning disable CS0612
|
||||
namespace Sonarr.Api.V3.Queue
|
||||
{
|
||||
[V3ApiController]
|
||||
public class QueueController : RestControllerWithSignalR<QueueResource, NzbDrone.Core.Queue.Queue>,
|
||||
IHandle<ObsoleteQueueUpdatedEvent>, IHandle<PendingReleasesUpdatedEvent>
|
||||
IHandle<QueueUpdatedEvent>, IHandle<PendingReleasesUpdatedEvent>
|
||||
{
|
||||
private readonly IObsoleteQueueService _queueService;
|
||||
private readonly IQueueService _queueService;
|
||||
private readonly IPendingReleaseService _pendingReleaseService;
|
||||
|
||||
private readonly QualityModelComparer _qualityComparer;
|
||||
@@ -39,7 +38,7 @@ namespace Sonarr.Api.V3.Queue
|
||||
private readonly IBlocklistService _blocklistService;
|
||||
|
||||
public QueueController(IBroadcastSignalRMessage broadcastSignalRMessage,
|
||||
IObsoleteQueueService queueService,
|
||||
IQueueService queueService,
|
||||
IPendingReleaseService pendingReleaseService,
|
||||
IQualityProfileService qualityProfileService,
|
||||
ITrackedDownloadService trackedDownloadService,
|
||||
@@ -74,7 +73,7 @@ namespace Sonarr.Api.V3.Queue
|
||||
[RestDeleteById]
|
||||
public void RemoveAction(int id, bool removeFromClient = true, bool blocklist = false, bool skipRedownload = false, bool changeCategory = false)
|
||||
{
|
||||
var pendingRelease = _pendingReleaseService.FindPendingQueueItemObsolete(id);
|
||||
var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id);
|
||||
|
||||
if (pendingRelease != null)
|
||||
{
|
||||
@@ -103,7 +102,7 @@ namespace Sonarr.Api.V3.Queue
|
||||
|
||||
foreach (var id in resource.Ids)
|
||||
{
|
||||
var pendingRelease = _pendingReleaseService.FindPendingQueueItemObsolete(id);
|
||||
var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id);
|
||||
|
||||
if (pendingRelease != null)
|
||||
{
|
||||
@@ -176,7 +175,7 @@ namespace Sonarr.Api.V3.Queue
|
||||
|
||||
var queue = _queueService.GetQueue();
|
||||
var filteredQueue = includeUnknownSeriesItems ? queue : queue.Where(q => q.Series != null);
|
||||
var pending = _pendingReleaseService.GetPendingQueueObsolete();
|
||||
var pending = _pendingReleaseService.GetPendingQueue();
|
||||
|
||||
var hasSeriesIdFilter = seriesIds is { Count: > 0 };
|
||||
var hasLanguageFilter = languages is { Count: > 0 };
|
||||
@@ -326,7 +325,7 @@ namespace Sonarr.Api.V3.Queue
|
||||
_blocklistService.Block(pendingRelease.RemoteEpisode, "Pending release manually blocklisted");
|
||||
}
|
||||
|
||||
_pendingReleaseService.RemovePendingQueueItemsObsolete(pendingRelease.Id);
|
||||
_pendingReleaseService.RemovePendingQueueItems(pendingRelease.Id);
|
||||
}
|
||||
|
||||
private TrackedDownload Remove(TrackedDownload trackedDownload, bool removeFromClient, bool blocklist, bool skipRedownload, bool changeCategory)
|
||||
@@ -395,7 +394,7 @@ namespace Sonarr.Api.V3.Queue
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public void Handle(ObsoleteQueueUpdatedEvent message)
|
||||
public void Handle(QueueUpdatedEvent message)
|
||||
{
|
||||
BroadcastResourceChange(ModelAction.Sync);
|
||||
}
|
||||
@@ -407,4 +406,3 @@ namespace Sonarr.Api.V3.Queue
|
||||
}
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0612
|
||||
|
||||
@@ -10,17 +10,16 @@ using NzbDrone.SignalR;
|
||||
using Sonarr.Http;
|
||||
using Sonarr.Http.REST;
|
||||
|
||||
#pragma warning disable CS0612
|
||||
namespace Sonarr.Api.V3.Queue
|
||||
{
|
||||
[V3ApiController("queue/details")]
|
||||
public class QueueDetailsController : RestControllerWithSignalR<QueueResource, NzbDrone.Core.Queue.Queue>,
|
||||
IHandle<ObsoleteQueueUpdatedEvent>, IHandle<PendingReleasesUpdatedEvent>
|
||||
IHandle<QueueUpdatedEvent>, IHandle<PendingReleasesUpdatedEvent>
|
||||
{
|
||||
private readonly IObsoleteQueueService _queueService;
|
||||
private readonly IQueueService _queueService;
|
||||
private readonly IPendingReleaseService _pendingReleaseService;
|
||||
|
||||
public QueueDetailsController(IBroadcastSignalRMessage broadcastSignalRMessage, IObsoleteQueueService queueService, IPendingReleaseService pendingReleaseService)
|
||||
public QueueDetailsController(IBroadcastSignalRMessage broadcastSignalRMessage, IQueueService queueService, IPendingReleaseService pendingReleaseService)
|
||||
: base(broadcastSignalRMessage)
|
||||
{
|
||||
_queueService = queueService;
|
||||
@@ -60,7 +59,7 @@ namespace Sonarr.Api.V3.Queue
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public void Handle(ObsoleteQueueUpdatedEvent message)
|
||||
public void Handle(QueueUpdatedEvent message)
|
||||
{
|
||||
BroadcastResourceChange(ModelAction.Sync);
|
||||
}
|
||||
@@ -72,4 +71,3 @@ namespace Sonarr.Api.V3.Queue
|
||||
}
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0612
|
||||
|
||||
@@ -11,7 +11,6 @@ using Sonarr.Api.V3.Episodes;
|
||||
using Sonarr.Api.V3.Series;
|
||||
using Sonarr.Http.REST;
|
||||
|
||||
#pragma warning disable CS0612
|
||||
namespace Sonarr.Api.V3.Queue
|
||||
{
|
||||
public class QueueResource : RestResource
|
||||
@@ -113,4 +112,3 @@ namespace Sonarr.Api.V3.Queue
|
||||
}
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0612
|
||||
|
||||
@@ -15,13 +15,13 @@ namespace Sonarr.Api.V3.Queue
|
||||
{
|
||||
[V3ApiController("queue/status")]
|
||||
public class QueueStatusController : RestControllerWithSignalR<QueueStatusResource, NzbDrone.Core.Queue.Queue>,
|
||||
IHandle<ObsoleteQueueUpdatedEvent>, IHandle<PendingReleasesUpdatedEvent>
|
||||
IHandle<QueueUpdatedEvent>, IHandle<PendingReleasesUpdatedEvent>
|
||||
{
|
||||
private readonly IObsoleteQueueService _queueService;
|
||||
private readonly IQueueService _queueService;
|
||||
private readonly IPendingReleaseService _pendingReleaseService;
|
||||
private readonly Debouncer _broadcastDebounce;
|
||||
|
||||
public QueueStatusController(IBroadcastSignalRMessage broadcastSignalRMessage, IObsoleteQueueService queueService, IPendingReleaseService pendingReleaseService)
|
||||
public QueueStatusController(IBroadcastSignalRMessage broadcastSignalRMessage, IQueueService queueService, IPendingReleaseService pendingReleaseService)
|
||||
: base(broadcastSignalRMessage)
|
||||
{
|
||||
_queueService = queueService;
|
||||
@@ -72,7 +72,7 @@ namespace Sonarr.Api.V3.Queue
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public void Handle(ObsoleteQueueUpdatedEvent message)
|
||||
public void Handle(QueueUpdatedEvent message)
|
||||
{
|
||||
_broadcastDebounce.Execute();
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using NzbDrone.Core.CustomFormats;
|
||||
using Sonarr.Http.ClientSchema;
|
||||
using Sonarr.Http.REST;
|
||||
|
||||
namespace Sonarr.Api.V5.CustomFormats
|
||||
{
|
||||
public class CustomFormatResource : RestResource
|
||||
{
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||
public override int Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public bool? IncludeCustomFormatWhenRenaming { get; set; }
|
||||
public List<CustomFormatSpecificationSchema>? Specifications { get; set; }
|
||||
}
|
||||
|
||||
public static class CustomFormatResourceMapper
|
||||
{
|
||||
public static CustomFormatResource ToResource(this CustomFormat model, bool includeDetails)
|
||||
{
|
||||
var resource = new CustomFormatResource
|
||||
{
|
||||
Id = model.Id,
|
||||
Name = model.Name
|
||||
};
|
||||
|
||||
if (includeDetails)
|
||||
{
|
||||
resource.IncludeCustomFormatWhenRenaming = model.IncludeCustomFormatWhenRenaming;
|
||||
resource.Specifications = model.Specifications.Select(x => x.ToSchema()).ToList();
|
||||
}
|
||||
|
||||
return resource;
|
||||
}
|
||||
|
||||
public static List<CustomFormatResource> ToResource(this IEnumerable<CustomFormat> models, bool includeDetails)
|
||||
{
|
||||
return models.Select(m => m.ToResource(includeDetails)).ToList();
|
||||
}
|
||||
|
||||
public static CustomFormat ToModel(this CustomFormatResource resource, List<ICustomFormatSpecification> specifications)
|
||||
{
|
||||
return new CustomFormat
|
||||
{
|
||||
Id = resource.Id,
|
||||
Name = resource.Name,
|
||||
IncludeCustomFormatWhenRenaming = resource.IncludeCustomFormatWhenRenaming ?? false,
|
||||
Specifications = resource.Specifications?.Select(x => MapSpecification(x, specifications)).ToList() ?? new List<ICustomFormatSpecification>()
|
||||
};
|
||||
}
|
||||
|
||||
private static ICustomFormatSpecification MapSpecification(CustomFormatSpecificationSchema resource, List<ICustomFormatSpecification> specifications)
|
||||
{
|
||||
var matchingSpec =
|
||||
specifications.SingleOrDefault(x => x.GetType().Name == resource.Implementation);
|
||||
|
||||
if (matchingSpec is null)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"{resource.Implementation} is not a valid specification implementation");
|
||||
}
|
||||
|
||||
var type = matchingSpec.GetType();
|
||||
|
||||
// Finding the exact current specification isn't possible given the dynamic nature of them and the possibility that multiple
|
||||
// of the same type exist within the same format. Passing in null is safe as long as there never exists a specification that
|
||||
// relies on additional privacy.
|
||||
var spec = (ICustomFormatSpecification)SchemaBuilder.ReadFromSchema(resource.Fields, type, null);
|
||||
spec.Name = resource.Name;
|
||||
spec.Negate = resource.Negate;
|
||||
spec.Required = resource.Required;
|
||||
return spec;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using NzbDrone.Core.CustomFormats;
|
||||
using Sonarr.Http.ClientSchema;
|
||||
using Sonarr.Http.REST;
|
||||
|
||||
namespace Sonarr.Api.V5.CustomFormats
|
||||
{
|
||||
public class CustomFormatSpecificationSchema : RestResource
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
public required string Implementation { get; set; }
|
||||
public required string ImplementationName { get; set; }
|
||||
public required string InfoLink { get; set; }
|
||||
public bool Negate { get; set; }
|
||||
public bool Required { get; set; }
|
||||
public required List<Field> Fields { get; set; }
|
||||
public List<CustomFormatSpecificationSchema>? Presets { get; set; }
|
||||
}
|
||||
|
||||
public static class CustomFormatSpecificationSchemaMapper
|
||||
{
|
||||
public static CustomFormatSpecificationSchema ToSchema(this ICustomFormatSpecification model)
|
||||
{
|
||||
return new CustomFormatSpecificationSchema
|
||||
{
|
||||
Name = model.Name,
|
||||
Implementation = model.GetType().Name,
|
||||
ImplementationName = model.ImplementationName,
|
||||
InfoLink = model.InfoLink,
|
||||
Negate = model.Negate,
|
||||
Required = model.Required,
|
||||
Fields = SchemaBuilder.ToSchema(model)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user