1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-03-06 13:30:00 -05:00

Compare commits

..

2 Commits

Author SHA1 Message Date
Mark McDowall
9e3b8230bc Fixed: Sub group parsing could result in extra brackets being parsed
Closes #7994
2025-08-01 09:31:17 -07:00
Mark McDowall
1c3c786335 Fixed: Treat TaoE and QxR as release group instead of encoder
Closes #7972
2025-08-01 09:31:17 -07:00
124 changed files with 2087 additions and 5040 deletions

View File

@@ -7,7 +7,6 @@
### Version V1.0.2 2024-01-03 - markus101 - Get user input from /dev/tty
### Version V1.0.3 2024-01-06 - StevieTV - exit script when it is ran from install directory
### Version V1.0.4 2025-04-05 - kaecyra - Allow user/group to be supplied via CLI, add unattended mode
### Version V1.0.5 2025-07-08 - bparkin1283 - use systemctl instead of service for stopping app
### Boilerplate Warning
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
@@ -168,10 +167,11 @@ if ! getent group "$app_guid" | grep -qw "$app_uid"; then
echo "Added User [$app_uid] to Group [$app_guid]"
fi
# Stop and disable the App if running
if [ $(systemctl is-active "$app") = "active" ]; then
systemctl disable --now -q "$app"
echo "Stopped and disabled existing $app"
# Stop the App if running
if service --status-all | grep -Fq "$app"; then
systemctl stop "$app"
systemctl disable "$app".service
echo "Stopped existing $app"
fi
# Create Appdata Directory

View File

@@ -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) ?? [];
};

View File

@@ -1,76 +0,0 @@
import React from 'react';
import Episode from 'Episode/Episode';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import Series from 'Series/Series';
import translate from 'Utilities/String/translate';
interface EpisodeCellContentProps {
episodes: Episode[];
isFullSeason: boolean;
seasonNumber?: number;
series?: Series;
}
export default function EpisodeCellContent({
episodes,
isFullSeason,
seasonNumber,
series,
}: EpisodeCellContentProps) {
if (episodes.length === 0) {
return '-';
}
if (isFullSeason && seasonNumber != null) {
return translate('SeasonNumberToken', { seasonNumber });
}
if (episodes.length === 1) {
const episode = episodes[0];
return (
<SeasonEpisodeNumber
seasonNumber={episode.seasonNumber}
episodeNumber={episode.episodeNumber}
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
seriesType={series?.seriesType}
alternateTitles={series?.alternateTitles}
sceneSeasonNumber={episode.sceneSeasonNumber}
sceneEpisodeNumber={episode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={episode.sceneAbsoluteEpisodeNumber}
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
/>
);
}
const firstEpisode = episodes[0];
const lastEpisode = episodes[episodes.length - 1];
return (
<>
<SeasonEpisodeNumber
seasonNumber={firstEpisode.seasonNumber}
episodeNumber={firstEpisode.episodeNumber}
absoluteEpisodeNumber={firstEpisode.absoluteEpisodeNumber}
seriesType={series?.seriesType}
alternateTitles={series?.alternateTitles}
sceneSeasonNumber={firstEpisode.sceneSeasonNumber}
sceneEpisodeNumber={firstEpisode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={firstEpisode.sceneAbsoluteEpisodeNumber}
unverifiedSceneNumbering={firstEpisode.unverifiedSceneNumbering}
/>
{' - '}
<SeasonEpisodeNumber
seasonNumber={lastEpisode.seasonNumber}
episodeNumber={lastEpisode.episodeNumber}
absoluteEpisodeNumber={lastEpisode.absoluteEpisodeNumber}
seriesType={series?.seriesType}
alternateTitles={series?.alternateTitles}
sceneSeasonNumber={lastEpisode.sceneSeasonNumber}
sceneEpisodeNumber={lastEpisode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={lastEpisode.sceneAbsoluteEpisodeNumber}
unverifiedSceneNumbering={lastEpisode.unverifiedSceneNumbering}
/>
</>
);
}

View File

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

View File

@@ -1,9 +0,0 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'episodeNumber': string;
'multiple': string;
'row': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -1,66 +0,0 @@
import React from 'react';
import Popover from 'Components/Tooltip/Popover';
import Episode from 'Episode/Episode';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import Series from 'Series/Series';
import translate from 'Utilities/String/translate';
import styles from './EpisodeTitleCellContent.css';
interface EpisodeTitleCellContentProps {
episodes: Episode[];
series?: Series;
}
export default function EpisodeTitleCellContent({
episodes,
series,
}: EpisodeTitleCellContentProps) {
if (episodes.length === 0 || !series) {
return '-';
}
if (episodes.length === 1) {
const episode = episodes[0];
return (
<EpisodeTitleLink
episodeId={episode.id}
seriesId={series.id}
episodeTitle={episode.title}
episodeEntity="episodes"
showOpenSeriesButton={true}
/>
);
}
return (
<Popover
anchor={
<span className={styles.multiple}>{translate('MultipleEpisodes')}</span>
}
title={translate('EpisodeTitles')}
body={
<>
{episodes.map((episode) => {
return (
<div key={episode.id} className={styles.row}>
<div className={styles.episodeNumber}>
{episode.episodeNumber}
</div>
<EpisodeTitleLink
episodeId={episode.id}
seriesId={series.id}
episodeTitle={episode.title}
episodeEntity="episodes"
showOpenSeriesButton={true}
/>
</div>
);
})}
</>
}
position="right"
/>
);
}

View File

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

View File

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

View File

@@ -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}
/>

View File

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

View File

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

View File

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

View File

@@ -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} />

View File

@@ -0,0 +1,32 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
function createQueueStatusSelector() {
return createSelector(
(state: AppState) => state.queue.status.isPopulated,
(state: AppState) => state.queue.status.item,
(state: AppState) => state.queue.options.includeUnknownSeriesItems,
(isPopulated, status, includeUnknownSeriesItems) => {
const {
errors,
warnings,
unknownErrors,
unknownWarnings,
count,
totalCount,
} = status;
return {
...status,
isPopulated,
count: includeUnknownSeriesItems ? totalCount : count,
errors: includeUnknownSeriesItems ? errors || unknownErrors : errors,
warnings: includeUnknownSeriesItems
? warnings || unknownWarnings
: warnings,
};
}
);
}
export default createQueueStatusSelector;

View File

@@ -1,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,
};
}

View File

@@ -1,4 +1,4 @@
.timeLeft {
.timeleft {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 100px;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
},
});
};

View File

@@ -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,
}));
};

View File

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

View File

@@ -0,0 +1,56 @@
import Queue from 'typings/Queue';
import AppSectionState, {
AppSectionFilterState,
AppSectionItemState,
Error,
PagedAppSectionState,
TableAppSectionState,
} from './AppSectionState';
export interface QueueStatus {
totalCount: number;
count: number;
unknownCount: number;
errors: boolean;
warnings: boolean;
unknownErrors: boolean;
unknownWarnings: boolean;
}
export interface QueueDetailsAppState extends AppSectionState<Queue> {
params: unknown;
}
export interface QueuePagedAppState
extends AppSectionState<Queue>,
AppSectionFilterState<Queue>,
PagedAppSectionState,
TableAppSectionState {
isGrabbing: boolean;
grabError: Error;
isRemoving: boolean;
removeError: Error;
}
export type RemovalMethod = 'removeFromClient' | 'changeCategory' | 'ignore';
export type BlocklistMethod =
| 'doNotBlocklist'
| 'blocklistAndSearch'
| 'blocklistOnly';
interface RemovalOptions {
removalMethod: RemovalMethod;
blocklistMethod: BlocklistMethod;
}
interface QueueAppState {
status: AppSectionItemState<QueueStatus>;
details: QueueDetailsAppState;
paged: QueuePagedAppState;
options: {
includeUnknownSeriesItems: boolean;
};
removalOptions: RemovalOptions;
}
export default QueueAppState;

View File

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

View File

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

View File

@@ -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}
/>
);
}

View File

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

View File

@@ -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()

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ function RelativeDateCell(props: RelativeDateCellProps) {
date,
includeSeconds = false,
includeTime = false,
component: Component = TableRowCell,
...otherProps
} = props;

View File

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

View File

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

View File

@@ -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 }),
});
}

View File

@@ -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 }),
});

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import classNames from 'classnames';
import React, { SyntheticEvent, useCallback, useState } from 'react';
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelect } from 'App/SelectContext';
import { REFRESH_SERIES, SERIES_SEARCH } from 'Commands/commandNames';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
@@ -123,31 +122,8 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
setIsDeleteSeriesModalOpen(false);
}, [setIsDeleteSeriesModalOpen]);
const [selectState, selectDispatch] = useSelect();
const onSelectPress = useCallback(
(event: SyntheticEvent<HTMLElement, MouseEvent>) => {
if (event.nativeEvent.ctrlKey || event.nativeEvent.metaKey) {
window.open(`/series/${titleSlug}`, '_blank');
return;
}
const shiftKey = event.nativeEvent.shiftKey;
selectDispatch({
type: 'toggleSelected',
id: seriesId,
isSelected: !selectState.selectedState[seriesId],
shiftKey,
});
},
[seriesId, selectState.selectedState, selectDispatch, titleSlug]
);
const link = `/series/${titleSlug}`;
const linkProps = isSelectMode ? { onPress: onSelectPress } : { to: link };
const elementStyle = {
width: `${posterWidth}px`,
height: `${posterHeight}px`,
@@ -199,7 +175,7 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
/>
) : null}
<Link className={styles.link} style={elementStyle} {...linkProps}>
<Link className={styles.link} style={elementStyle} to={link}>
<SeriesPoster
style={elementStyle}
images={images}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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);

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

View File

@@ -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,
}));
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,6 @@ namespace NzbDrone.Common.Extensions
{
public static class DateTimeExtensions
{
public static readonly DateTime EpochTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
public static bool InNextDays(this DateTime dateTime, int days)
{
return InNext(dateTime, new TimeSpan(days, 0, 0, 0));
@@ -45,10 +43,5 @@ namespace NzbDrone.Common.Extensions
{
return dateTime.AddTicks(-(dateTime.Ticks % TimeSpan.TicksPerSecond));
}
public static DateTime WithTicksFrom(this DateTime dateTime, DateTime other)
{
return dateTime.WithoutTicks().AddTicks(other.Ticks % TimeSpan.TicksPerSecond);
}
}
}

View File

@@ -1,88 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using FluentAssertions;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Datastore.Migration;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.Datastore.Migration
{
[TestFixture]
public class nzb_su_url_to_nzb_lifeFixture : MigrationTest<nzb_su_url_to_nzb_life>
{
[TestCase("Newznab", "https://api.nzb.su")]
[TestCase("Newznab", "http://api.nzb.su")]
public void should_replace_old_url(string impl, string baseUrl)
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("Indexers").Row(new
{
Name = "Nzb.su",
Implementation = impl,
Settings = new NewznabSettings219
{
BaseUrl = baseUrl,
ApiPath = "/api"
}.ToJson(),
ConfigContract = impl + "Settings",
EnableInteractiveSearch = false
});
});
var items = db.Query<IndexerDefinition219>("SELECT * FROM \"Indexers\"");
items.Should().HaveCount(1);
items.First().Settings.ToObject<NewznabSettings219>().BaseUrl.Should().Be(baseUrl.Replace("su", "life"));
}
[TestCase("Newznab", "https://api.indexer.com")]
public void should_not_replace_different_url(string impl, string baseUrl)
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("Indexers").Row(new
{
Name = "Indexer.com",
Implementation = impl,
Settings = new NewznabSettings219
{
BaseUrl = baseUrl,
ApiPath = "/api"
}.ToJson(),
ConfigContract = impl + "Settings",
EnableInteractiveSearch = false
});
});
var items = db.Query<IndexerDefinition219>("SELECT * FROM \"Indexers\"");
items.Should().HaveCount(1);
items.First().Settings.ToObject<NewznabSettings219>().BaseUrl.Should().Be(baseUrl);
}
}
internal class IndexerDefinition219
{
public int Id { get; set; }
public string Name { get; set; }
public JObject Settings { get; set; }
public int Priority { get; set; }
public string Implementation { get; set; }
public string ConfigContract { get; set; }
public bool EnableRss { get; set; }
public bool EnableAutomaticSearch { get; set; }
public bool EnableInteractiveSearch { get; set; }
public HashSet<int> Tags { get; set; }
public int DownloadClientId { get; set; }
public int SeasonSearchMaximumSingleEpisodeAge { get; set; }
}
internal class NewznabSettings219
{
public string BaseUrl { get; set; }
public string ApiPath { get; set; }
}
}

View File

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

View File

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

View File

@@ -59,7 +59,6 @@ namespace NzbDrone.Core.Test.IndexerTests
public void should_return_season_time_for_season_packs()
{
var settings = new TorznabSettings();
settings.SeedCriteria.SeasonPackSeedGoal = (int)SeasonPackSeedGoal.UseSeasonPackSeedGoal;
settings.SeedCriteria.SeasonPackSeedTime = 10;
Mocker.GetMock<ICachedIndexerSettingsProvider>()
@@ -86,71 +85,5 @@ namespace NzbDrone.Core.Test.IndexerTests
result.Should().NotBeNull();
result.SeedTime.Should().Be(TimeSpan.FromMinutes(10));
}
[Test]
public void should_return_season_ratio_for_season_packs_when_set()
{
var settings = new TorznabSettings();
settings.SeedCriteria.SeasonPackSeedGoal = (int)SeasonPackSeedGoal.UseSeasonPackSeedGoal;
settings.SeedCriteria.SeedRatio = 1.0;
settings.SeedCriteria.SeasonPackSeedRatio = 10.0;
Mocker.GetMock<ICachedIndexerSettingsProvider>()
.Setup(v => v.GetSettings(It.IsAny<int>()))
.Returns(new CachedIndexerSettings
{
FailDownloads = new HashSet<FailDownloads> { FailDownloads.Executables },
SeedCriteriaSettings = settings.SeedCriteria
});
var result = Subject.GetSeedConfiguration(new RemoteEpisode
{
Release = new ReleaseInfo
{
DownloadProtocol = DownloadProtocol.Torrent,
IndexerId = 1
},
ParsedEpisodeInfo = new ParsedEpisodeInfo
{
FullSeason = true
}
});
result.Should().NotBeNull();
result.Ratio.Should().Be(10.0);
}
[Test]
public void should_return_standard_ratio_for_season_packs_when_not_set()
{
var settings = new TorznabSettings();
settings.SeedCriteria.SeasonPackSeedGoal = (int)SeasonPackSeedGoal.UseStandardSeedGoal;
settings.SeedCriteria.SeedRatio = 1.0;
settings.SeedCriteria.SeasonPackSeedRatio = 10.0;
Mocker.GetMock<ICachedIndexerSettingsProvider>()
.Setup(v => v.GetSettings(It.IsAny<int>()))
.Returns(new CachedIndexerSettings
{
FailDownloads = new HashSet<FailDownloads> { FailDownloads.Executables },
SeedCriteriaSettings = settings.SeedCriteria
});
var result = Subject.GetSeedConfiguration(new RemoteEpisode
{
Release = new ReleaseInfo
{
DownloadProtocol = DownloadProtocol.Torrent,
IndexerId = 1
},
ParsedEpisodeInfo = new ParsedEpisodeInfo
{
FullSeason = true
}
});
result.Should().NotBeNull();
result.Ratio.Should().Be(1.0);
}
}
}

View File

@@ -1,118 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Tv;
using NzbDrone.Test.Common;
namespace NzbDrone.Core.Test.MediaFiles.UpdateEpisodeFileServiceTests
{
[TestFixture]
public class ChangeFileDateForFileFixture : CoreTest<UpdateEpisodeFileService>
{
private readonly DateTime _veryOldAirDateUtc = new(1965, 01, 01, 0, 0, 0, 512, 512, DateTimeKind.Utc);
private DateTime _lastWrite = new(2025, 07, 27, 12, 0, 0, 512, 512, DateTimeKind.Utc);
private Series _series;
private EpisodeFile _episodeFile;
private string _seriesFolder;
private List<Episode> _episodes;
[SetUp]
public void Setup()
{
_seriesFolder = @"C:\Test\TV\Series Title".AsOsAgnostic();
_series = Builder<Series>.CreateNew()
.With(s => s.Path = _seriesFolder)
.Build();
_episodes = Builder<Episode>.CreateListOfSize(1)
.All()
.With(e => e.AirDateUtc = _lastWrite.AddDays(2))
.Build()
.ToList();
_episodeFile = Builder<EpisodeFile>.CreateNew()
.With(f => f.Path = Path.Combine(_series.Path, "Season 1", "Series Title - S01E01.mkv").AsOsAgnostic())
.With(f => f.RelativePath = @"Season 1\Series Title - S01E01.mkv".AsOsAgnostic())
.Build();
Mocker.GetMock<IDiskProvider>()
.Setup(x => x.FileGetLastWrite(_episodeFile.Path))
.Returns(() => _lastWrite);
Mocker.GetMock<IDiskProvider>()
.Setup(x => x.FileSetLastWriteTime(_episodeFile.Path, It.IsAny<DateTime>()))
.Callback<string, DateTime>((path, dateTime) =>
{
_lastWrite = dateTime.Kind == DateTimeKind.Utc
? dateTime
: dateTime.ToUniversalTime();
});
Mocker.GetMock<IConfigService>()
.Setup(x => x.FileDate)
.Returns(FileDateType.LocalAirDate);
}
[Test]
public void should_change_date_once_only()
{
var previousWrite = new DateTime(_lastWrite.Ticks, _lastWrite.Kind);
Subject.ChangeFileDateForFile(_episodeFile, _series, _episodes);
Subject.ChangeFileDateForFile(_episodeFile, _series, _episodes);
Mocker.GetMock<IDiskProvider>()
.Verify(v => v.FileSetLastWriteTime(_episodeFile.Path, It.IsAny<DateTime>()), Times.Once());
var actualWriteTime = Mocker.GetMock<IDiskProvider>().Object.FileGetLastWrite(_episodeFile.Path).ToLocalTime();
actualWriteTime.Should().Be(_episodes[0].AirDateUtc.Value.ToLocalTime().WithTicksFrom(previousWrite));
}
[Test]
public void should_clamp_mtime_on_posix()
{
PosixOnly();
var previousWrite = new DateTime(_lastWrite.Ticks, _lastWrite.Kind);
_episodes[0].AirDateUtc = _veryOldAirDateUtc;
Subject.ChangeFileDateForFile(_episodeFile, _series, _episodes);
Subject.ChangeFileDateForFile(_episodeFile, _series, _episodes);
Mocker.GetMock<IDiskProvider>()
.Verify(v => v.FileSetLastWriteTime(_episodeFile.Path, It.IsAny<DateTime>()), Times.Once());
var actualWriteTime = Mocker.GetMock<IDiskProvider>().Object.FileGetLastWrite(_episodeFile.Path).ToLocalTime();
actualWriteTime.Should().Be(DateTimeExtensions.EpochTime.ToLocalTime().WithTicksFrom(previousWrite));
}
[Test]
public void should_not_clamp_mtime_on_windows()
{
WindowsOnly();
var previousWrite = new DateTime(_lastWrite.Ticks, _lastWrite.Kind);
_episodes[0].AirDateUtc = _veryOldAirDateUtc;
Subject.ChangeFileDateForFile(_episodeFile, _series, _episodes);
Subject.ChangeFileDateForFile(_episodeFile, _series, _episodes);
Mocker.GetMock<IDiskProvider>()
.Verify(v => v.FileSetLastWriteTime(_episodeFile.Path, It.IsAny<DateTime>()), Times.Once());
var actualWriteTime = Mocker.GetMock<IDiskProvider>().Object.FileGetLastWrite(_episodeFile.Path).ToLocalTime();
actualWriteTime.Should().Be(_episodes[0].AirDateUtc.Value.ToLocalTime().WithTicksFrom(previousWrite));
}
}
}

View File

@@ -86,8 +86,6 @@ namespace NzbDrone.Core.Test.ParserTests
[TestCase("Series Title S01 1080p Eng Fra [mkvonly]")]
[TestCase("Series Title S01 Eng Fre Multi Subs 720p [H264 mp4]")]
[TestCase("Series-Title-S01-[DVDRip]-H264-Fra-Ac3-2-0-Eng-5-1")]
[TestCase("Series Title S01 1080p FR ENG [mkvonly]")]
[TestCase("Series Title S01 1080p ENG FR [mkvonly]")]
public void should_parse_language_french_english(string postTitle)
{
var result = LanguageParser.ParseLanguages(postTitle);
@@ -147,7 +145,6 @@ namespace NzbDrone.Core.Test.ParserTests
}
[TestCase("Title.the.Series.2009.S01E14.Japanese.HDTV.XviD-LOL")]
[TestCase("[Erai-raws] To Be Series - 14 (JA) [1080p CR WEB-DL AVC AAC][MultiSub]")]
public void should_parse_language_japanese(string postTitle)
{
var result = LanguageParser.ParseLanguages(postTitle);

View File

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

View File

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

View File

@@ -1,16 +0,0 @@
using FluentMigrator;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(219)]
public class nzb_su_url_to_nzb_life : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
Execute.Sql("UPDATE \"Indexers\" SET \"Settings\" = replace(\"Settings\", '//api.nzb.su', '//api.nzb.life')" +
"WHERE \"Implementation\" = 'Newznab'" +
"AND \"Settings\" LIKE '%//api.nzb.su%'");
}
}
}

View File

@@ -1,66 +0,0 @@
using System.Collections.Generic;
using System.Data;
using System.Linq;
using Dapper;
using FluentMigrator;
using Newtonsoft.Json.Linq;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(229)]
public class enable_season_pack_seeding_goal : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
Execute.WithConnection(SetSeasonPackSeedingGoal);
}
private void SetSeasonPackSeedingGoal(IDbConnection conn, IDbTransaction tran)
{
var updatedIndexers = new List<object>();
using var selectCommand = conn.CreateCommand();
selectCommand.Transaction = tran;
selectCommand.CommandText = "SELECT * FROM \"Indexers\"";
using var reader = selectCommand.ExecuteReader();
while (reader.Read())
{
var idIndex = reader.GetOrdinal("Id");
var settingsIndex = reader.GetOrdinal("Settings");
var id = reader.GetInt32(idIndex);
var settings = Json.Deserialize<Dictionary<string, object>>(reader.GetString(settingsIndex));
if (settings.TryGetValue("seedCriteria", out var seedCriteriaToken) && seedCriteriaToken is JObject seedCriteria)
{
if (seedCriteria?["seasonPackSeedTime"] != null)
{
seedCriteria["seasonPackSeedGoal"] = 1;
if (seedCriteria["seedRatio"] != null)
{
seedCriteria["seasonPackSeedRatio"] = seedCriteria["seedRatio"];
}
updatedIndexers.Add(new
{
Settings = settings.ToJson(),
Id = id,
});
}
}
}
if (updatedIndexers.Any())
{
var updateSql = "UPDATE \"Indexers\" SET \"Settings\" = @Settings WHERE \"Id\" = @Id";
conn.Execute(updateSql, updatedIndexers, transaction: tran);
}
}
}
}

View File

@@ -1,155 +0,0 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Runtime.Serialization;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace NzbDrone.Core.Download.Clients.Tribler
{
public enum DownloadStatus
{
[EnumMember(Value = @"WAITING4HASHCHECK")]
Waiting4HashCheck = 0,
[EnumMember(Value = @"HASHCHECKING")]
Hashchecking = 1,
[EnumMember(Value = @"METADATA")]
Metadata = 2,
[EnumMember(Value = @"DOWNLOADING")]
Downloading = 3,
[EnumMember(Value = @"SEEDING")]
Seeding = 4,
[EnumMember(Value = @"STOPPED")]
Stopped = 5,
[EnumMember(Value = @"ALLOCATING_DISKSPACE")]
AllocatingDiskspace = 6,
[EnumMember(Value = @"EXIT_NODES")]
Exitnodes = 7,
[EnumMember(Value = @"CIRCUITS")]
Circuits = 8,
[EnumMember(Value = @"STOPPED_ON_ERROR")]
StoppedOnError = 9,
[EnumMember(Value = @"LOADING")]
Loading = 10,
}
public class Trackers
{
public string Url { get; set; }
[JsonProperty("peers")]
public object Peers { get; set; }
[JsonProperty("status")]
public string Status { get; set; }
}
public class Download
{
public string Name { get; set; }
public float? Progress { get; set; }
public string Infohash { get; set; }
public bool? AnonDownload { get; set; }
public float? Availability { get; set; }
public double? Eta { get; set; }
public long? TotalPieces { get; set; }
public long? NumSeeds { get; set; }
public long? AllTimeUpload { get; set; }
public long? AllTimeDownload { get; set; }
[JsonProperty("status")]
[JsonConverter(typeof(StringEnumConverter))]
public DownloadStatus? Status { get; set; }
public int? StatusCode { get; set; }
public float? AllTimeRatio { get; set; }
public long? TimeAdded { get; set; }
public long? MaxUploadSpeed { get; set; }
public long? MaxDownloadSpeed { get; set; }
public long? Hops { get; set; }
public bool? SafeSeeding { get; set; }
public string Error { get; set; }
public long? TotalDown { get; set; }
public long? Size { get; set; }
public string Destination { get; set; }
public float? SpeedDown { get; set; }
public float? SpeedUp { get; set; }
public long? NumPeers { get; set; }
public List<Trackers> Trackers { get; set; }
}
public class DownloadsResponse
{
public List<Download> Downloads { get; set; }
}
public class AddDownloadRequest
{
[JsonProperty("anon_hops")]
public long? AnonymityHops { get; set; }
[JsonProperty("safe_seeding")]
public bool? SafeSeeding { get; set; }
public string Destination { get; set; }
[JsonProperty("uri", Required = Newtonsoft.Json.Required.Always)]
[Required(AllowEmptyStrings = true)]
public string Uri { get; set; }
}
public class AddDownloadResponse
{
public string Infohash { get; set; }
public bool? Started { get; set; }
}
public class RemoveDownloadRequest
{
[JsonProperty("remove_data")]
public bool? RemoveData { get; set; }
}
public class DeleteDownloadResponse
{
public bool? Removed { get; set; }
public string Infohash { get; set; }
}
public class UpdateDownloadRequest
{
[JsonProperty("anon_hops")]
public long? AnonHops { get; set; }
[JsonProperty("selected_files")]
public List<int> Selected_files { get; set; }
public string State { get; set; }
}
public class UpdateDownloadResponse
{
public bool? Modified { get; set; }
public string Infohash { get; set; }
}
public class File
{
public long? Size { get; set; }
public long? Index { get; set; }
public string Name { get; set; }
public float? Progress { get; set; }
public bool? Included { get; set; }
}
public class GetFilesResponse
{
public List<File> Files { get; set; }
}
}

View File

@@ -1,189 +0,0 @@
using System.Runtime.Serialization;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace NzbDrone.Core.Indexers.Tribler
{
public class TriblerSettingsResponse
{
public Settings Settings { get; set; }
}
public class Settings
{
public Api Api { get; set; }
public bool Statistics { get; set; }
[JsonProperty("content_discovery_community")]
public ContentDiscoveryCommunity ContentDiscoveryCommunity { get; set; }
public Database Database { get; set; }
[JsonProperty("dht_discovery")]
public DHTDiscovery DHTDiscovery { get; set; }
[JsonProperty("knowledge_community")]
public KnowledgeCommunity KnowledgeCommunity { get; set; }
public LibTorrent LibTorrent { get; set; }
public Recommender Recommender { get; set; }
public Rendezvous RecoRendezvousmmender { get; set; }
[JsonProperty("torrent_checker")]
public TorrentChecker TorrentChecker { get; set; }
[JsonProperty("tunnel_community")]
public TunnelCommunity TunnelCommunity { get; set; }
public Versioning Versioning { get; set; }
[JsonProperty("watch_folder")]
public WatchFolder WatchFolder { get; set; }
[JsonProperty("state_dir")]
public string StateDir { get; set; }
[JsonProperty("memory_db")]
public bool? MemoryDB { get; set; }
}
public class Api
{
[JsonProperty("http_enabled")]
public bool HttpEnabled { get; set; }
[JsonProperty("http_port")]
public int HttpPort { get; set; }
[JsonProperty("http_host")]
public string HttpHost { get; set; }
[JsonProperty("https_enabled")]
public bool HttpsEnabled { get; set; }
[JsonProperty("https_port")]
public int HttpsPort { get; set; }
[JsonProperty("https_host")]
public string HttpsHost { get; set; }
[JsonProperty("https_certfile")]
public string HttpsCertFile { get; set; }
[JsonProperty("http_port_running")]
public int HttpPortRunning { get; set; }
[JsonProperty("https_port_running")]
public int HttpsPortRunning { get; set; }
}
public class ContentDiscoveryCommunity
{
public bool? Enabled { get; set; }
}
public class Database
{
public bool? Enabled { get; set; }
}
public class DHTDiscovery
{
public bool? Enabled { get; set; }
}
public class KnowledgeCommunity
{
public bool? Enabled { get; set; }
}
public class LibTorrent
{
[JsonProperty("download_defaults")]
public LibTorrentDownloadDefaults DownloadDefaults { get; set; }
// contains a lot more data, but it's not needed currently
}
public class Recommender
{
public bool? Enabled { get; set; }
}
public class Rendezvous
{
public bool? Enabled { get; set; }
}
public class TorrentChecker
{
[JsonProperty("enabled")]
public bool? Enabled { get; set; }
}
public class TunnelCommunity
{
[JsonProperty("enabled")]
public bool? Enabled { get; set; }
[JsonProperty("min_circuits")]
public int? MinCircuits { get; set; }
[JsonProperty("max_circuits")]
public int? MaxCircuits { get; set; }
}
public class Versioning
{
[JsonProperty("enabled")]
public bool? Enabled { get; set; }
}
public class WatchFolder
{
[JsonProperty("enabled")]
public bool? Enabled { get; set; }
[JsonProperty("directory")]
public string Directory { get; set; }
[JsonProperty("check_interval")]
public int? CheckInterval { get; set; }
}
public class LibTorrentDownloadDefaults
{
[JsonProperty("anonymity_enabled")]
public bool? AnonymityEnabled { get; set; }
[JsonProperty("number_hops")]
public int? NumberHops { get; set; }
[JsonProperty("safeseeding_enabled")]
public bool? SafeSeedingEnabled { get; set; }
[JsonProperty("saveas")]
public string SaveAS { get; set; }
[JsonProperty("seeding_mode")]
[JsonConverter(typeof(StringEnumConverter))]
public DownloadDefaultsSeedingMode? SeedingMode { get; set; }
[JsonProperty("seeding_ratio")]
public double? SeedingRatio { get; set; }
[JsonProperty("seeding_time")]
public double? SeedingTime { get; set; }
}
public enum DownloadDefaultsSeedingMode
{
[EnumMember(Value = @"ratio")]
Ratio = 0,
[EnumMember(Value = @"forever")]
Forever = 1,
[EnumMember(Value = @"time")]
Time = 2,
[EnumMember(Value = @"never")]
Never = 3,
}
}

View File

@@ -1,298 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FluentValidation.Results;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Blocklisting;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Indexers.Tribler;
using NzbDrone.Core.Localization;
using NzbDrone.Core.MediaFiles.TorrentInfo;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.RemotePathMappings;
using NzbDrone.Core.ThingiProvider;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.Download.Clients.Tribler
{
public class TriblerDownloadClient : TorrentClientBase<TriblerDownloadSettings>
{
private readonly ITriblerDownloadClientProxy _proxy;
public TriblerDownloadClient(
ITriblerDownloadClientProxy triblerDownloadClientProxy,
ITorrentFileInfoReader torrentFileInfoReader,
IHttpClient httpClient,
IConfigService configService,
IDiskProvider diskProvider,
IRemotePathMappingService remotePathMappingService,
ILocalizationService localizationService,
IBlocklistService blocklistService,
Logger logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, localizationService, blocklistService, logger)
{
_proxy = triblerDownloadClientProxy;
}
public override string Name => "Tribler";
public override ProviderMessage Message => new ProviderMessage(_localizationService.GetLocalizedString("DownloadClientTriblerProviderMessage", new Dictionary<string, object> { { "clientName", Name }, { "clientVersionRange", "8.0.7" } }), ProviderMessageType.Warning);
public override bool PreferTorrentFile => false;
public override IEnumerable<DownloadClientItem> GetItems()
{
var configAsync = _proxy.GetConfig(Settings);
var items = new List<DownloadClientItem>();
var downloads = _proxy.GetDownloads(Settings);
foreach (var download in downloads)
{
// If totalsize == 0 the torrent is a magnet downloading metadata
if (download.Size == null || download.Size == 0)
{
continue;
}
var item = new DownloadClientItem
{
DownloadId = download.Infohash,
Title = download.Name,
DownloadClientInfo = DownloadClientItemClientInfo.FromDownloadClient(this, false)
};
// some concurrency could make this faster.
var files = _proxy.GetDownloadFiles(Settings, download);
item.OutputPath = new OsPath(download.Destination);
if (files.Count == 1)
{
item.OutputPath += files.First().Name;
}
else
{
item.OutputPath += item.Title;
}
item.TotalSize = (long)download.Size;
item.RemainingSize = (long)(download.Size * (1 - download.Progress));
item.SeedRatio = download.AllTimeRatio;
if (download.Eta.HasValue)
{
if (download.Eta.Value >= TimeSpan.FromDays(365).TotalSeconds)
{
item.RemainingTime = TimeSpan.FromDays(365);
}
else if (download.Eta.Value < 0)
{
item.RemainingTime = TimeSpan.FromSeconds(0);
}
else
{
item.RemainingTime = TimeSpan.FromSeconds(download.Eta.Value);
}
}
item.Message = download.Error;
// tribler always saves files unencrypted to disk.
item.IsEncrypted = false;
switch (download.Status)
{
case DownloadStatus.Hashchecking:
case DownloadStatus.Waiting4HashCheck:
case DownloadStatus.Circuits:
case DownloadStatus.Exitnodes:
case DownloadStatus.Downloading:
item.Status = DownloadItemStatus.Downloading;
break;
case DownloadStatus.Metadata:
case DownloadStatus.AllocatingDiskspace:
item.Status = DownloadItemStatus.Queued;
break;
case DownloadStatus.Seeding:
case DownloadStatus.Stopped:
item.Status = DownloadItemStatus.Completed;
break;
case DownloadStatus.StoppedOnError:
item.Status = DownloadItemStatus.Failed;
break;
case DownloadStatus.Loading:
default: // new status in API? default to downloading
item.Message = "Unknown download state: " + download.Status;
_logger.Info(item.Message);
item.Status = DownloadItemStatus.Downloading;
break;
}
// Override status if completed, but not finished downloading
if (download.Status == DownloadStatus.Stopped && download.Progress < 1)
{
item.Status = DownloadItemStatus.Paused;
}
if (download.Error != null && download.Error.Length > 0)
{
item.Status = DownloadItemStatus.Warning;
item.Message = download.Error;
}
item.CanBeRemoved = item.CanMoveFiles = HasReachedSeedLimit(download, configAsync);
items.Add(item);
}
return items;
}
public override void RemoveItem(DownloadClientItem item, bool deleteData)
{
_proxy.RemoveDownload(Settings, item, deleteData);
}
public override DownloadClientInfo GetStatus()
{
var config = _proxy.GetConfig(Settings);
var destDir = config.Settings.LibTorrent.DownloadDefaults.SaveAS;
if (Settings.TvCategory.IsNotNullOrWhiteSpace())
{
destDir = string.Format("{0}/.{1}", destDir, Settings.TvCategory);
}
return new DownloadClientInfo
{
IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost",
OutputRootFolders = new List<OsPath> { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(destDir)) }
};
}
protected static bool HasReachedSeedLimit(Download torrent, TriblerSettingsResponse config)
{
if (config == null)
{
throw new ArgumentNullException(nameof(config));
}
if (torrent == null)
{
throw new ArgumentNullException(nameof(torrent));
}
// if download is still running then it's not finished.
if (torrent.Status != DownloadStatus.Stopped)
{
return false;
}
switch (config.Settings.LibTorrent.DownloadDefaults.SeedingMode)
{
case DownloadDefaultsSeedingMode.Ratio:
return torrent.AllTimeRatio.HasValue
&& torrent.AllTimeRatio >= config.Settings.LibTorrent.DownloadDefaults.SeedingRatio;
case DownloadDefaultsSeedingMode.Time:
var downloadStarted = DateTimeOffset.FromUnixTimeSeconds(torrent.TimeAdded.Value);
var maxSeedingTime = TimeSpan.FromSeconds(config.Settings.LibTorrent.DownloadDefaults.SeedingTime ?? 0);
return torrent.TimeAdded.HasValue
&& downloadStarted.Add(maxSeedingTime) < DateTimeOffset.Now;
case DownloadDefaultsSeedingMode.Never:
return true;
case DownloadDefaultsSeedingMode.Forever:
default:
return false;
}
}
protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string hash, string magnetLink)
{
var addDownloadRequestObject = new AddDownloadRequest
{
Destination = GetDownloadDirectory(),
Uri = magnetLink,
SafeSeeding = Settings.SafeSeeding,
AnonymityHops = Settings.AnonymityLevel
};
return _proxy.AddFromMagnetLink(Settings, addDownloadRequestObject);
}
protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string hash, string filename, byte[] fileContent)
{
// TODO: Tribler 8.x does support adding from a torrent file, but it's not a simple put command.
throw new NotSupportedException("Tribler does not support torrent files, only magnet links");
}
protected override void Test(List<ValidationFailure> failures)
{
failures.AddIfNotNull(TestConnection());
if (failures.HasErrors())
{
return;
}
}
protected string GetDownloadDirectory()
{
if (Settings.TvDirectory.IsNotNullOrWhiteSpace())
{
return Settings.TvDirectory;
}
if (!Settings.TvCategory.IsNotNullOrWhiteSpace())
{
return null;
}
var config = _proxy.GetConfig(Settings);
var destDir = config.Settings.LibTorrent.DownloadDefaults.SaveAS;
return $"{destDir.TrimEnd('/')}/{Settings.TvCategory}";
}
protected ValidationFailure TestConnection()
{
try
{
var downloads = GetItems();
return null;
}
catch (DownloadClientAuthenticationException ex)
{
_logger.Error(ex, ex.Message);
return new ValidationFailure("ApiKey", _localizationService.GetLocalizedString("DownloadClientValidationApiKeyIncorrect"));
}
catch (DownloadClientUnavailableException ex)
{
_logger.Error(ex, ex.Message);
return new NzbDroneValidationFailure("Host", _localizationService.GetLocalizedString("DownloadClientValidationUnableToConnect", new Dictionary<string, object> { { "clientName", Name } }))
{
DetailedDescription = ex.Message
};
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to test");
return new NzbDroneValidationFailure(string.Empty, _localizationService.GetLocalizedString("DownloadClientValidationUnknownException", new Dictionary<string, object> { { "exception", ex.Message } }));
}
}
}
}

View File

@@ -1,119 +0,0 @@
using System.Collections.Generic;
using System.Net.Http;
using NLog;
using NzbDrone.Common.Http;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Indexers.Tribler;
namespace NzbDrone.Core.Download.Clients.Tribler
{
public interface ITriblerDownloadClientProxy
{
List<Download> GetDownloads(TriblerDownloadSettings settings);
List<File> GetDownloadFiles(TriblerDownloadSettings settings, Download downloadItem);
TriblerSettingsResponse GetConfig(TriblerDownloadSettings settings);
void RemoveDownload(TriblerDownloadSettings settings, DownloadClientItem item, bool deleteData);
string AddFromMagnetLink(TriblerDownloadSettings settings, AddDownloadRequest downloadRequest);
}
public class TriblerDownloadClientProxy : ITriblerDownloadClientProxy
{
protected readonly IHttpClient _httpClient;
private readonly Logger _logger;
public TriblerDownloadClientProxy(IHttpClient httpClient, Logger logger)
{
_httpClient = httpClient;
_logger = logger;
}
private HttpRequestBuilder GetRequestBuilder(TriblerDownloadSettings settings, string relativePath = null)
{
var baseUrl = HttpRequestBuilder.BuildBaseUrl(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase);
baseUrl = HttpUri.CombinePath(baseUrl, relativePath);
var requestBuilder = new HttpRequestBuilder(baseUrl)
.Accept(HttpAccept.Json);
requestBuilder.Headers.Add("X-Api-Key", settings.ApiKey);
requestBuilder.LogResponseContent = true;
return requestBuilder;
}
private T ProcessRequest<T>(HttpRequestBuilder requestBuilder)
where T : new()
{
return ProcessRequest<T>(requestBuilder.Build());
}
private T ProcessRequest<T>(HttpRequest requestBuilder)
where T : new()
{
var httpRequest = requestBuilder;
_logger.Debug("Url: {0}", httpRequest.Url);
try
{
var response = _httpClient.Execute(httpRequest);
return Json.Deserialize<T>(response.Content);
}
catch (HttpException ex)
{
if (ex.Response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
throw new DownloadClientAuthenticationException("Unauthorized - AuthToken is invalid", ex);
}
throw new DownloadClientUnavailableException("Unable to connect to Tribler. Status Code: {0}", ex.Response.StatusCode, ex);
}
}
public TriblerSettingsResponse GetConfig(TriblerDownloadSettings settings)
{
var configRequest = GetRequestBuilder(settings, "api/settings");
return ProcessRequest<TriblerSettingsResponse>(configRequest);
}
public List<File> GetDownloadFiles(TriblerDownloadSettings settings, Download downloadItem)
{
var filesRequest = GetRequestBuilder(settings, "api/downloads/" + downloadItem.Infohash + "/files");
return ProcessRequest<GetFilesResponse>(filesRequest).Files;
}
public List<Download> GetDownloads(TriblerDownloadSettings settings)
{
var downloadRequest = GetRequestBuilder(settings, "api/downloads");
var downloads = ProcessRequest<DownloadsResponse>(downloadRequest);
return downloads.Downloads;
}
public void RemoveDownload(TriblerDownloadSettings settings, DownloadClientItem item, bool deleteData)
{
var deleteDownloadRequestObject = new RemoveDownloadRequest
{
RemoveData = deleteData
};
var deleteRequestBuilder = GetRequestBuilder(settings, "api/downloads/" + item.DownloadId.ToLower());
deleteRequestBuilder.Method = HttpMethod.Delete;
var deleteRequest = deleteRequestBuilder.Build();
deleteRequest.SetContent(Json.ToJson(deleteDownloadRequestObject));
ProcessRequest<DeleteDownloadResponse>(deleteRequest);
}
public string AddFromMagnetLink(TriblerDownloadSettings settings, AddDownloadRequest downloadRequest)
{
var addDownloadRequestBuilder = GetRequestBuilder(settings, "api/downloads");
addDownloadRequestBuilder.Method = HttpMethod.Put;
var addDownloadRequest = addDownloadRequestBuilder.Build();
addDownloadRequest.SetContent(Json.ToJson(downloadRequest));
return ProcessRequest<AddDownloadResponse>(addDownloadRequest).Infohash;
}
}
}

View File

@@ -1,74 +0,0 @@
using System.Text.RegularExpressions;
using FluentValidation;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.ThingiProvider;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.Download.Clients.Tribler
{
public class TriblerSettingsValidator : AbstractValidator<TriblerDownloadSettings>
{
public TriblerSettingsValidator()
{
RuleFor(c => c.Host).ValidHost();
RuleFor(c => c.Port).InclusiveBetween(1, 65535);
RuleFor(c => c.UrlBase).ValidUrlBase();
RuleFor(c => c.ApiKey).NotEmpty();
RuleFor(c => c.TvCategory).Matches(@"^\.?[-a-z]*$", RegexOptions.IgnoreCase).WithMessage("Allowed characters a-z and -");
RuleFor(c => c.TvCategory).Empty()
.When(c => c.TvDirectory.IsNotNullOrWhiteSpace())
.WithMessage("Cannot use Category and Directory");
RuleFor(c => c.AnonymityLevel).GreaterThanOrEqualTo(0);
}
}
public class TriblerDownloadSettings : IProviderConfig
{
private static readonly TriblerSettingsValidator Validator = new TriblerSettingsValidator();
public TriblerDownloadSettings()
{
Host = "localhost";
Port = 20100;
UrlBase = "";
AnonymityLevel = 1;
SafeSeeding = true;
}
[FieldDefinition(1, Label = "Host", Type = FieldType.Textbox)]
public string Host { get; set; }
[FieldDefinition(2, Label = "Port", Type = FieldType.Textbox)]
public int Port { get; set; }
[FieldDefinition(3, Label = "UseSsl", Type = FieldType.Checkbox, HelpText = "DownloadClientSettingsUseSslHelpText")]
public bool UseSsl { get; set; }
[FieldDefinition(4, Label = "UrlBase", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientSettingsUrlBaseHelpText")]
[FieldToken(TokenField.HelpText, "UrlBase", "clientName", "Tribler")]
[FieldToken(TokenField.HelpText, "UrlBase", "url", "http://[host]:[port]/[urlBase]")]
public string UrlBase { get; set; }
[FieldDefinition(5, Label = "ApiKey", Type = FieldType.Textbox, Privacy = PrivacyLevel.ApiKey, HelpText = "DownloadClientTriblerSettingsApiKeyHelpText")]
public string ApiKey { get; set; }
[FieldDefinition(6, Label = "Category", Type = FieldType.Textbox, HelpText = "DownloadClientSettingsCategoryHelpText")]
public string TvCategory { get; set; }
[FieldDefinition(7, Label = "Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientTriblerSettingsDirectoryHelpText")]
public string TvDirectory { get; set; }
[FieldDefinition(8, Label = "DownloadClientTriblerSettingsAnonymityLevel", Type = FieldType.Number, HelpText = "DownloadClientTriblerSettingsAnonymityLevelHelpText")]
[FieldToken(TokenField.HelpText, "DownloadClientTriblerSettingsAnonymityLevel", "url", "https://www.tribler.org/anonymity.html")]
public int AnonymityLevel { get; set; }
[FieldDefinition(9, Label = "DownloadClientTriblerSettingsSafeSeeding", Type = FieldType.Checkbox, HelpText = "DownloadClientTriblerSettingsSafeSeedingHelpText")]
public bool SafeSeeding { get; set; }
public NzbDroneValidationResult Validate()
{
return new NzbDroneValidationResult(Validator.Validate(this));
}
}
}

View File

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

View File

@@ -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();
}
}
}

View File

@@ -50,7 +50,7 @@ namespace NzbDrone.Core.Indexers.Newznab
{
yield return GetDefinition("DOGnzb", GetSettings("https://api.dognzb.cr"));
yield return GetDefinition("DrunkenSlug", GetSettings("https://drunkenslug.com"));
yield return GetDefinition("Nzb.life", GetSettings("https://api.nzb.life"));
yield return GetDefinition("Nzb.su", GetSettings("https://api.nzb.su"));
yield return GetDefinition("NZBCat", GetSettings("https://nzb.cat"));
yield return GetDefinition("NZBFinder.ws", GetSettings("https://nzbfinder.ws", categories: new[] { 5030, 5040, 5045 }));
yield return GetDefinition("NZBgeek", GetSettings("https://api.nzbgeek.info"));

View File

@@ -13,10 +13,10 @@ namespace NzbDrone.Core.Indexers.Newznab
{
public class NewznabSettingsValidator : AbstractValidator<NewznabSettings>
{
private static readonly string[] ApiKeyAllowList =
private static readonly string[] ApiKeyWhiteList =
{
"nzbs.org",
"nzb.life",
"nzb.su",
"dognzb.cr",
"nzbplanet.net",
"nzbid.org",
@@ -26,7 +26,7 @@ namespace NzbDrone.Core.Indexers.Newznab
private static bool ShouldHaveApiKey(NewznabSettings settings)
{
return settings.BaseUrl != null && ApiKeyAllowList.Any(c => settings.BaseUrl.ToLowerInvariant().Contains(c));
return settings.BaseUrl != null && ApiKeyWhiteList.Any(c => settings.BaseUrl.ToLowerInvariant().Contains(c));
}
private static readonly Regex AdditionalParametersRegex = new(@"(&.+?\=.+?)+", RegexOptions.Compiled);

View File

@@ -1,11 +0,0 @@
using NzbDrone.Core.Annotations;
namespace NzbDrone.Core.Indexers;
public enum SeasonPackSeedGoal
{
[FieldOption(Label = "IndexerSettingsSeasonPackSeedGoalUseStandardGoals")]
UseStandardSeedGoal = 0,
[FieldOption(Label = "IndexerSettingsSeasonPackSeedGoalUseSeasonPackGoals")]
UseSeasonPackSeedGoal = 1
}

View File

@@ -49,16 +49,12 @@ namespace NzbDrone.Core.Indexers
return null;
}
var useSeasonPackSeedGoal = (SeasonPackSeedGoal)seedCriteria.SeasonPackSeedGoal == SeasonPackSeedGoal.UseSeasonPackSeedGoal;
var seedConfig = new TorrentSeedConfiguration
{
Ratio = (fullSeason && useSeasonPackSeedGoal)
? seedCriteria.SeasonPackSeedRatio
: seedCriteria.SeedRatio
Ratio = seedCriteria.SeedRatio
};
var seedTime = (fullSeason && useSeasonPackSeedGoal) ? seedCriteria.SeasonPackSeedTime : seedCriteria.SeedTime;
var seedTime = fullSeason ? seedCriteria.SeasonPackSeedTime : seedCriteria.SeedTime;
if (seedTime.HasValue)
{
seedConfig.SeedTime = TimeSpan.FromMinutes(seedTime.Value);

View File

@@ -17,10 +17,6 @@ namespace NzbDrone.Core.Indexers
.When(c => c.SeedTime.HasValue)
.AsWarning().WithMessage("Should be greater than zero");
RuleFor(c => c.SeasonPackSeedRatio).GreaterThan(0.0)
.When(c => c.SeasonPackSeedRatio.HasValue)
.AsWarning().WithMessage("Should be greater than zero");
RuleFor(c => c.SeasonPackSeedTime).GreaterThan(0)
.When(c => c.SeasonPackSeedTime.HasValue)
.AsWarning().WithMessage("Should be greater than zero");
@@ -31,11 +27,6 @@ namespace NzbDrone.Core.Indexers
.When(c => c.SeedRatio > 0.0)
.AsWarning()
.WithMessage($"Under {seedRatioMinimum} leads to H&R");
RuleFor(c => c.SeasonPackSeedRatio).GreaterThanOrEqualTo(seedRatioMinimum)
.When(c => c.SeasonPackSeedRatio > 0.0)
.AsWarning()
.WithMessage($"Under {seedRatioMinimum} leads to H&R");
}
if (seedTimeMinimum != 0)
@@ -64,13 +55,7 @@ namespace NzbDrone.Core.Indexers
[FieldDefinition(1, Type = FieldType.Number, Label = "IndexerSettingsSeedTime", Unit = "minutes", HelpText = "IndexerSettingsSeedTimeHelpText", Advanced = true)]
public int? SeedTime { get; set; }
[FieldDefinition(2, Type = FieldType.Select, Label = "IndexerSettingsSeasonPackSeedGoal", SelectOptions = typeof(SeasonPackSeedGoal), HelpText = "IndexerSettingsSeasonPackSeedGoalHelpText", Advanced = true)]
public int SeasonPackSeedGoal { get; set; }
[FieldDefinition(3, Type = FieldType.Number, Label = "IndexerSettingsSeasonPackSeedRatio", HelpText = "IndexerSettingsSeasonPackSeedRatioHelpText", Advanced = true)]
public double? SeasonPackSeedRatio { get; set; }
[FieldDefinition(4, Type = FieldType.Number, Label = "IndexerSettingsSeasonPackSeedTime", Unit = "minutes", HelpText = "IndexerSettingsSeasonPackSeedTimeHelpText", Advanced = true)]
[FieldDefinition(2, Type = FieldType.Number, Label = "Season-Pack Seed Time", Unit = "minutes", HelpText = "IndexerSettingsSeasonPackSeedTimeHelpText", Advanced = true)]
public int? SeasonPackSeedTime { get; set; }
}
}

View File

@@ -12,11 +12,11 @@ namespace NzbDrone.Core.Indexers.Torznab
{
public class TorznabSettingsValidator : AbstractValidator<TorznabSettings>
{
private static readonly string[] ApiKeyAllowList = Array.Empty<string>();
private static readonly string[] ApiKeyWhiteList = Array.Empty<string>();
private static bool ShouldHaveApiKey(TorznabSettings settings)
{
return settings.BaseUrl != null && ApiKeyAllowList.Any(c => settings.BaseUrl.ToLowerInvariant().Contains(c));
return settings.BaseUrl != null && ApiKeyWhiteList.Any(c => settings.BaseUrl.ToLowerInvariant().Contains(c));
}
private static readonly Regex AdditionalParametersRegex = new(@"(&.+?\=.+?)+", RegexOptions.Compiled);

View File

@@ -1874,6 +1874,7 @@
"DownloadClientQbittorrentValidationQueueingNotEnabledDetail": "La cua de torrent no està activada a la configuració del qBittorrent. Activeu-lo a qBittorrent o seleccioneu 'Last' com a prioritat.",
"DownloadClientQbittorrentValidationRemovesAtRatioLimit": "qBittorrent està configurat per a eliminar els torrents quan arribin al límit de la relació de compartició",
"DownloadClientTransmissionSettingsUrlBaseHelpText": "Afegeix un prefix a l'URL rpc de {clientName}, ex. {url}, per defecte a {defaultUrl}",
"DownloadClientUTorrentProviderMessage": "uTorrent té un historial d'inclusió de criptominers, programari maliciós i anuncis, us animem a triar un client diferent.",
"DownloadClientValidationAuthenticationFailureDetail": "Verifiqueu el vostre nom d'usuari i contrasenya. Verifiqueu també si el servidor que executa {appName} no està bloquejat per accedir a {clientName} per les limitacions de WhiteList a la configuració {clientName}.",
"DownloadClientValidationCategoryMissingDetail": "La categoria que heu introduït no existeix a {clientName}. Primer creeu-lo a {clientName}.",
"DownloadClientValidationSslConnectFailure": "No s'ha pogut connectar a través de SSL",
@@ -2165,10 +2166,5 @@
"NotificationsAppriseSettingsIncludePosterHelpText": "Inclou el cartell al missatge",
"MonitorEpisodesModalInfo": "Aquesta opció només ajustarà quins episodis o temporades són monitorats en les sèries. Seleccionar Cap deixarà de monitorar les sèries",
"EpisodeMonitoring": "Monitoratge d'episodis",
"NotificationsAppriseSettingsIncludePoster": "Inclou el cartell",
"UserRejectedExtensions": "Extensions addicionals d'arxiu rebutjades",
"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+)"
"NotificationsAppriseSettingsIncludePoster": "Inclou el cartell"
}

View File

@@ -537,6 +537,7 @@
"DownloadClientFreeboxUnableToReachFreebox": "Nelze se připojit k Freebox API. Zkontrolujte nastavení 'Host', 'Port' nebo 'Použít SSL'. (Chyba: {exceptionMessage})",
"IndexerHDBitsSettingsCodecsHelpText": "Pokud nespecifikováno, použijí se všechny možnosti.",
"DownloadClientDownloadStationValidationSharedFolderMissing": "Sdílená složka neexistuje",
"DownloadClientUTorrentProviderMessage": "uTorrent je známý tím, že zahrnuje cryptominery, malware a reklamy, důrazně vám doporučujeme zvolit jiného klienta.",
"DownloadClientDownloadStationValidationNoDefaultDestinationDetail": "Přihlaste se do vaší DiskStation jako {username} a ručně to nastavte v nastavení DownloadStation pod BT/HTTP/FTP/NZB -> Umístění.",
"IndexerLongTermStatusUnavailableHealthCheckMessage": "Indexery nedostupné z důvodu selhání delším než 6 hodin: {indexerNames}",
"DownloadClientFloodSettingsRemovalInfo": "{appName} se postará o automatické mazání torrentů podle aktuálních kritérií seedování v Nastavení -> Indexery",

View File

@@ -549,13 +549,7 @@
"DownloadClientStatusSingleClientHealthCheckMessage": "Download clients unavailable due to failures: {downloadClientNames}",
"DownloadClientTransmissionSettingsDirectoryHelpText": "Optional location to put downloads in, leave blank to use the default Transmission location",
"DownloadClientTransmissionSettingsUrlBaseHelpText": "Adds a prefix to the {clientName} rpc url, eg {url}, defaults to '{defaultUrl}'",
"DownloadClientTriblerSettingsAnonymityLevel": "Anonymity level",
"DownloadClientTriblerSettingsAnonymityLevelHelpText": "Number of proxies to use when downloading content. To disable set to 0. Proxies reduce download/upload speed. See {url}",
"DownloadClientTriblerSettingsApiKeyHelpText": "[api].key from triblerd.conf",
"DownloadClientTriblerSettingsDirectoryHelpText": "Optional location to put downloads in, leave blank to use the default Tribler location",
"DownloadClientTriblerSettingsSafeSeeding": "Safe Seeding",
"DownloadClientTriblerSettingsSafeSeedingHelpText": "When enabled, only seed through proxies.",
"DownloadClientTriblerProviderMessage": "The tribler integration is highly experimental. Tested against {clientName} version {clientVersionRange}.",
"DownloadClientUTorrentProviderMessage": "uTorrent has a history of including cryptominers, malware and ads, we strongly encourage you to choose a different client.",
"DownloadClientUTorrentTorrentStateError": "uTorrent is reporting an error",
"DownloadClientUnavailable": "Download Client Unavailable",
"DownloadClientValidationApiKeyIncorrect": "API Key Incorrect",
@@ -662,7 +656,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 +665,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",
@@ -1028,14 +1019,8 @@
"IndexerSettingsRejectBlocklistedTorrentHashesHelpText": "If a torrent is blocked by hash it may not properly be rejected during RSS/Search for some indexers, enabling this will allow it to be rejected after the torrent is grabbed, but before it is sent to the client.",
"IndexerSettingsRssUrl": "RSS URL",
"IndexerSettingsRssUrlHelpText": "Enter to URL to an {indexer} compatible RSS feed",
"IndexerSettingsSeasonPackSeedGoal": "Seeding Goal for Season Packs",
"IndexerSettingsSeasonPackSeedGoalHelpText": "Choose whether to use different seeding goals for season packs",
"IndexerSettingsSeasonPackSeedGoalUseStandardGoals": "Use Standard Goals",
"IndexerSettingsSeasonPackSeedGoalUseSeasonPackGoals": "Use Season Pack Goals",
"IndexerSettingsSeasonPackSeedRatio": "Season Pack Seed Ratio",
"IndexerSettingsSeasonPackSeedRatioHelpText": "The ratio a season pack torrent should reach before stopping, empty uses the download client's default. Ratio should be at least 1.0 and follow the indexers rules",
"IndexerSettingsSeasonPackSeedTime": "Season Pack Seed Time",
"IndexerSettingsSeasonPackSeedTimeHelpText": "The time a season pack torrent should be seeded before stopping, empty uses the download client's default",
"IndexerSettingsSeasonPackSeedTime": "Season-Pack Seed Time",
"IndexerSettingsSeasonPackSeedTimeHelpText": "The time a season-pack torrent should be seeded before stopping, empty uses the download client's default",
"IndexerSettingsSeedRatio": "Seed Ratio",
"IndexerSettingsSeedRatioHelpText": "The ratio a torrent should reach before stopping, empty uses the download client's default. Ratio should be at least 1.0 and follow the indexers rules",
"IndexerSettingsSeedTime": "Seed Time",
@@ -1289,7 +1274,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",

View File

@@ -2151,6 +2151,7 @@
"NotificationsTelegramSettingsLinkPreviewHelpText": "Determina qué enlaces se previsualizarán en las notificaciones de Telegram. Elige 'Ninguno' para deshabilitarlo",
"MediaInfoFootNote2": "MediaInfo AudioLanguages excluye el inglés si es el único idioma. Usa MediaInfo AudioLanguagesAll para incluir solo el inglés",
"ReleaseSource": "Fuente de lanzamiento",
"DownloadClientUTorrentProviderMessage": "uTorrent tiene un amplio historial de incluir criptomineros, malware y publicidad, por lo que recomendamos encarecidamente que elijas un cliente diferente.",
"NotificationsPushcutSettingsIncludePoster": "Incluir póster",
"NotificationsPushcutSettingsIncludePosterHelpText": "Incluir póster con notificación",
"NotificationsPushcutSettingsMetadataLinks": "Enlaces de metadatos",
@@ -2165,10 +2166,5 @@
"NotificationsAppriseSettingsIncludePosterHelpText": "Incluir póster en el mensaje",
"EpisodeMonitoring": "Monitorización de episodios",
"MonitorEpisodes": "Monitorizar episodios",
"MonitorEpisodesModalInfo": "Esta opción solo ajustará qué episodios o temporadas son monitorizados en las series. Seleccionar Ninguno dejará de monitorizar las series",
"DownloadClientQbittorrentSettingsAddSeriesTags": "Añadir etiquetas de series",
"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)"
"MonitorEpisodesModalInfo": "Esta opción solo ajustará qué episodios o temporadas son monitorizados en las series. Seleccionar Ninguno dejará de monitorizar las series"
}

View File

@@ -816,7 +816,7 @@
"CollapseMultipleEpisodesHelpText": "Tiivistä useat samana päivänä esitettävät jaksot.",
"CalendarLegendSeriesFinaleTooltip": "Sarjan tai kauden päätösjakso",
"CalendarLegendSeriesPremiereTooltip": "Sarjan tai kauden pilottijakso",
"ClickToChangeSeries": "Vaihda sarja klikkaamalla",
"ClickToChangeSeries": "Muuta sarjaa klikkaamalla",
"CloneIndexer": "Monista hakupalvelu",
"Close": "Sulje",
"ClearBlocklist": "Tyhjennä estolista",
@@ -1190,7 +1190,7 @@
"AddedDate": "Lisätty: {date}",
"Anime": "Anime",
"Any": "Mikä tahansa",
"ClickToChangeSeason": "Vaihda tuotantokausi klikkaamalla",
"ClickToChangeSeason": "Vaihda tuotantokautta painamalla tästä",
"CountSelectedFile": "{selectedCount} tiedosto on valittu",
"SingleEpisodeInvalidFormat": "Yksittäinen jakso: virheellinen kaava",
"Underscore": "Alaviiva",
@@ -1242,7 +1242,7 @@
"AuthenticationRequiredPasswordConfirmationHelpTextWarning": "Vahvista uusi salasana",
"Category": "Kategoria",
"ChownGroup": "chown-ryhmä",
"ClickToChangeEpisode": "Vaihda jakso klikkaamalla",
"ClickToChangeEpisode": "Vaihda jaksoa painamalla tästä",
"CompletedDownloadHandling": "Valmistuneiden latausten käsittely",
"Condition": "Ehto",
"Continuing": "Jatkuu",
@@ -1834,13 +1834,13 @@
"ImportListsMyAnimeListSettingsListStatus": "Listan tila",
"ImportListStatusAllUnavailableHealthCheckMessage": "Mitkään listat eivät ole virheiden vuoksi käytettävissä",
"MetadataKometaDeprecatedSetting": "Poistunut",
"NotificationsTelegramSettingsMetadataLinksHelpText": "Lisää lähetettäviin ilmoituksiin linkit median metatietoihin.",
"NotificationsTelegramSettingsMetadataLinksHelpText": "Lisää lähetettäviin ilmoituksiin linkit sarjojen metatietoihin.",
"OnFileImport": "Kun tiedosto tuodaan",
"OnFileUpgrade": "Kun tiedosto päivitetään",
"ReleaseProfile": "Julkaisuprofiili",
"ShowTags": "Näytä tunnisteet",
"TodayAt": "Tänään klo {time}",
"ClickToChangeReleaseType": "Vaihda julkaisun tyyppi klikkaamalla",
"ClickToChangeReleaseType": "Vaihda julkaisun tyyppiä painamalla tästä",
"CustomFormatsSpecificationSource": "Lähde",
"DownloadClientQbittorrentTorrentStateMissingFiles": "qBittorrent ilmoittaa puuttuvista tiedostoista",
"DownloadClientSabnzbdValidationCheckBeforeDownload": "Poista SABnbzd:n \"Tarkista ennen lataamista\" -asetus käytöstä",
@@ -1862,7 +1862,7 @@
"ReleaseGroupFootNote": "Vaihtoehtoisesti voit hallita lyhennystä tavujen enimmäismäärän perusteella, ellipsi (...) mukaan lukien. Sekä lyhennystä lopusta (esim. \"{Julkaisuryhmä:30}\"), että alusta (esim. \"{Julkaisuryhmä:-30}\") tuetaan.",
"InstallMajorVersionUpdateMessage": "Tämä päivitys asentaa uuden pääversion, joka ei välttämättä ole yhteensopiva laitteistosi kanssa. Haluatko varmasti asentaa päivityksen?",
"MinimumCustomFormatScoreIncrementHelpText": "Pienin vaadittu olemassa olevien ja uusien julkaisujen välinen mukautetun muodon pisteytyksen korotus ennen kuin {appName} tulkitsee julkaisun päivitykseksi.",
"NotificationsGotifySettingsMetadataLinksHelpText": "Lisää lähetettäviin ilmoituksiin linkit median metatietoihin.",
"NotificationsGotifySettingsMetadataLinksHelpText": "Lisää lähetettäviin ilmoituksiin linkit sarjojen metatietoihin.",
"NotificationsPlexSettingsServerHelpText": "Valitse tunnistautumisen jälkeen palvelin Plex.tv-tililtä.",
"EpisodeTitleFootNote": "Vaihtoehtoisesti voit hallita lyhennystä tavujen enimmäismäärän perusteella, ellipsi (...) mukaan lukien. Sekä lyhennystä lopusta (esim. \"{jakson nimi:30}\"), että alusta (esim. \"{jakson nimi:-30}\") tuetaan. Tarvittaessa jaksojen nimet lyhennetään automaattisesti järjestelmän rajoitukseen.",
"SeriesFootNote": "Vaihtoehtoisesti voit hallita lyhennystä tavujen enimmäismäärän perusteella, ellipsi (...) mukaan lukien. Sekä lyhennystä lopusta (esim. \"{Sarjan nimi:30}\"), että alusta (esim. \"{Sarjan nimi:-30}\") tuetaan.",
@@ -2152,23 +2152,16 @@
"QualityDefinitionsSizeNotice": "Kokorajoitukset on siirretty laatuprofiileihin",
"NotificationsTelegramSettingsLinkPreview": "Linkin esikatselu",
"NotificationsTelegramSettingsLinkPreviewHelpText": "Määrittää minkä linkin esikatselu Telegram-ilmoituksessa näytetään. Poista käytöstä valitsemalla \"Ei mitään\".",
"DownloadClientUTorrentProviderMessage": "Koska uTorrent on tunnettu crypto-, haitta- and mainossisällöstä ja sovelluksista, suosittelemme qBittorrentin, Delugen ja ruTorrentin kaltaisten vaihtoehtojen käyttämistä.",
"NotificationsPushcutSettingsIncludePoster": "Sisällytä juliste",
"NotificationsPushcutSettingsIncludePosterHelpText": "Näytä juliste ilmoituksessa.",
"NotificationsPushcutSettingsMetadataLinks": "Metatietolinkit",
"NotificationsPushcutSettingsMetadataLinksHelpText": "Lisää lähetettäviin ilmoituksiin linkit median metatietoihin.",
"NotificationsPushcutSettingsMetadataLinksHelpText": "Lisää lähetettäviin ilmoituksiin linkit sarjojen metatietoihin.",
"AutoTaggingSpecificationNetwork": "Verkot",
"DownloadClientItemErrorMessage": "{clientName} ilmoittaa virheestä: {message}",
"EpisodesInSeason": "Tuotantokaudessa on {episodeCount} jaksoa",
"CloneImportList": "Monista tuontilista",
"DefaultNameCopiedImportList": "{name} (kopio)",
"EpisodeMonitoring": "Jaksojen valvonta",
"MonitorEpisodes": "Valvo jaksoja",
"DownloadClientQbittorrentSettingsAddSeriesTagsHelpText": "Merkitse uudet latauspalveluun lisätyt torrentit sarjatunnisteilla (aBittorrent 4.1.0+).",
"MonitorEpisodesModalInfo": "Tämä määrittää vain mitä jaksoja tai kausia sarjasta valvotaan. Valinta \"Ei mitään\" lopettaa sarjan valvonnan.",
"NotificationsAppriseSettingsIncludePoster": "Sisällytä juliste",
"NotificationsAppriseSettingsIncludePosterHelpText": "Sisällytä julisteet viesteihin.",
"UserRejectedExtensions": "Lisää estettyjä tiedostopäätteitä",
"UserRejectedExtensionsHelpText": "Pilkuin eroteltu listaus hylättävistä tiedostopäätteistä. Lisäksi \"Hylättävät lautaukset\"-asetuksen tulee olla käytössä hakupalvelukohtaisesti.",
"UserRejectedExtensionsTextsExamples": "Esimerkiksi: \".ext, .xyz\" tai \"ext,xyz\".",
"DownloadClientQbittorrentSettingsAddSeriesTags": "Lisää sarjan tunnisteet"
"EpisodeMonitoring": "Jakson Valvonta",
"MonitorEpisodes": "Valvo Jaksoja"
}

View File

@@ -444,7 +444,7 @@
"NoSeriesFoundImportOrAdd": "Aucune série trouvée. Pour commencer, vous souhaiterez importer votre série existante ou ajouter une nouvelle série.",
"ICalFeedHelpText": "Copiez cette URL dans votre/vos client(s) ou cliquez pour abonner si votre navigateur est compatible avec webcal",
"SeasonFolderFormat": "Format du dossier de saison",
"QualitiesHelpText": "Les qualités placées en haut de la liste sont privilégiées même si elles ne sont pas cochées. Les qualités d'un même groupe sont égales. Seules les qualités cochées sont recherchées",
"QualitiesHelpText": "Les qualités plus élevées dans la liste sont plus préférées. Les qualités au sein dun même groupe sont égales. Seules les qualités vérifiées sont recherchées",
"PrioritySettings": "Priorité: {priority}",
"ImportExistingSeries": "Importer une série existante",
"RootFolderSelectFreeSpace": "{freeSpace} Libre",
@@ -800,7 +800,7 @@
"UpdaterLogFiles": "Journaux du programme de mise à jour",
"UpgradeUntil": "Mise à niveau jusqu'à",
"UpgradeUntilCustomFormatScore": "Mise à niveau jusqu'au score de format personnalisé",
"UpgradeUntilCustomFormatScoreEpisodeHelpText": "Une fois que la qualité minimum est atteinte ou dépassée et que le score de format personnalisé est atteint, {appName} ne récupérera plus les sorties d'épisodes",
"UpgradeUntilCustomFormatScoreEpisodeHelpText": "Une fois ce score de format personnalisé atteint, {appName} ne récupérera plus les sorties d'épisodes",
"UrlBase": "URL de base",
"UseHardlinksInsteadOfCopy": "Utiliser les liens durs au lieu de copier",
"UseSeasonFolder": "Utiliser le dossier de la saison",
@@ -904,7 +904,7 @@
"UnmappedFilesOnly": "Fichiers non mappés uniquement",
"UnmonitorSpecialsEpisodesDescription": "Annulez la surveillance de tous les épisodes spéciaux sans modifier le statut surveillé des autres épisodes",
"UpdateUiNotWritableHealthCheckMessage": "Impossible d'installer la mise à jour, car le dossier de l'interface utilisateur « {uiFolder} » n'est pas accessible en écriture par l'utilisateur « {userName} ».",
"UpgradeUntilEpisodeHelpText": "Une fois cette qualité atteinte, {appName} ne téléchargera plus d'épisodes une fois le que le score du format personnalisé est atteint ou dépassé",
"UpgradeUntilEpisodeHelpText": "Une fois cette qualité atteinte, {appName} ne téléchargera plus d'épisodes",
"UpgradeUntilThisQualityIsMetOrExceeded": "Mise à niveau jusqu'à ce que cette qualité soit atteinte ou dépassée",
"UseProxy": "Utiliser le proxy",
"WaitingToImport": "En attente d'import",
@@ -2017,7 +2017,7 @@
"ImportListsTraktSettingsPopularListTypeTrendingShows": "Spectacles en vogue",
"ImportListsTraktSettingsPopularName": "Liste populaire de Trakt",
"ImportListsTraktSettingsRating": "Evaluation",
"ImportListsTraktSettingsRatingSeriesHelpText": "Filtrer les séries par plage de classement (0-100)",
"ImportListsTraktSettingsRatingSeriesHelpText": "Série de filtres par plage de valeurs nominales (0-100)",
"ImportListsTraktSettingsWatchedListFilterSeriesHelpText": "Si le type de liste est surveillé, sélectionnez le type de série que vous souhaitez importer",
"ImportListsTraktSettingsWatchListSorting": "Tri de la liste de surveillance",
"ImportListsTraktSettingsWatchListSortingHelpText": "Si le type de liste est surveillé, sélectionnez l'ordre de tri de la liste",
@@ -2123,46 +2123,5 @@
"LastSearched": "Dernière recherche",
"FolderNameTokens": "Jetons de nom de dossier",
"ManageCustomFormats": "Gérer les formats personnalisés",
"Menu": "Menu",
"Fallback": "Alternative",
"MetadataKometaDeprecatedSetting": "Obsolète",
"AutoTaggingSpecificationNetwork": "Réseau(x)",
"DefaultNameCopiedImportList": "{name} - Copie",
"DownloadClientItemErrorMessage": "{clientName} a rapporté une erreur : {message}",
"EditSizes": "Modifier les dimensions",
"NotificationsGotifySettingsPreferredMetadataLink": "Lien de métadonnées préféré",
"NotificationsPushcutSettingsMetadataLinksHelpText": "Ajouter un lien vers les métadonnées de la série lors de l'envoie d'une notification",
"NotificationsTelegramSettingsLinkPreviewHelpText": "Détermine quel lien sera aperçu dans la notification Telegram. Choisir 'Aucun' pour désactiver",
"DoneEditingSizes": "Terminer la modification des dimensions",
"EpisodeMonitoring": "Suivi des épisodes",
"ManageFormats": "Gérer les formats",
"MinuteShorthand": "m",
"MonitorEpisodes": "Surveiller les épisodes",
"NotificationsGotifySettingsPreferredMetadataLinkHelpText": "Lien de métadonnées pour les clients qui ne peuvent avoir qu'un seul lien",
"NotificationsSettingsWebhookHeaders": "En-têtes",
"NotificationsTelegramSettingsIncludeInstanceNameHelpText": "Inclure le nom de l'instance dans la notification de façon facultative",
"EpisodesInSeason": "{episodeCount} épisodes dans la saison",
"FileSize": "Taille de fichier",
"Maximum": "Maximum",
"Minimum": "Minimum",
"MinimumCustomFormatScoreIncrement": "Incrément minimal du score du format personnalisé",
"Minute": "minute",
"NotificationsPushcutSettingsIncludePoster": "Inclure l'affiche",
"NotificationsPushcutSettingsIncludePosterHelpText": "Inclure l'affiche avec les notifications",
"NotificationsTelegramSettingsLinkPreview": "Aperçu du lien",
"FavoriteFolderAdd": "Ajouter un dossier favori",
"FavoriteFolderRemove": "Supprimer le dossier favori",
"DownloadClientQbittorrentSettingsAddSeriesTags": "Ajouter des tags de séries",
"DownloadClientQbittorrentSettingsAddSeriesTagsHelpText": "Ajouter des tags de séries aux nouveaux torrents ajoutés au client de téléchargement (qBittorrent 4.1.0+)",
"FavoriteFolders": "Dossier favori",
"MinimumCustomFormatScoreIncrementHelpText": "Amélioration minimale requise du score de format personnalisé entre les versions existantes et nouvelles avant que {appName} ne le considère comme une mise à niveau",
"MonitorEpisodesModalInfo": "Ce paramètre n'ajustera que les épisodes ou saisons qui seront surveillés dans une série. Sélectionner Aucun retirera la surveillance de la série",
"NotificationsTelegramSettingsIncludeInstanceName": "Inclure le nom de l'instance dans le titre",
"NotificationsPushcutSettingsMetadataLinks": "Lien de métadonnées",
"UserRejectedExtensions": "Extensions de fichiers rejetées supplémentaires",
"UserRejectedExtensionsHelpText": "Liste séparée par des virgules des extensions de fichiers à échouer (“Échouer les téléchargements” doit également être activé dans lindexeur)",
"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 lutilisateur"
"Menu": "Menu"
}

View File

@@ -2152,6 +2152,7 @@
"QualityDefinitionsSizeNotice": "As restrições de tamanho foram transferidas para Perfis de Qualidade",
"NotificationsTelegramSettingsLinkPreview": "Prévia do Link",
"NotificationsTelegramSettingsLinkPreviewHelpText": "Determina qual link será visualizado na notificação do Telegram. Escolha 'Nenhum' para desativar",
"DownloadClientUTorrentProviderMessage": "O uTorrent tem um histórico de incluir criptomineradores, malware e anúncios, recomendamos fortemente que você escolha um cliente diferente.",
"NotificationsPushcutSettingsIncludePoster": "Incluir pôster",
"NotificationsPushcutSettingsMetadataLinks": "Links de metadados",
"NotificationsPushcutSettingsIncludePosterHelpText": "Incluir pôster com notificação",
@@ -2165,10 +2166,5 @@
"NotificationsAppriseSettingsIncludePosterHelpText": "Incluir pôster na mensagem",
"EpisodeMonitoring": "Monitoramento do Episódio",
"MonitorEpisodes": "Monitorar Episódios",
"MonitorEpisodesModalInfo": "Esta configuração ajustará apenas quais episódios ou temporadas serão monitorados dentro de uma série. Selecionar Nenhum desativará o monitoramento da série",
"UserRejectedExtensions": "Extensões de Arquivos Rejeitadas Adicionais",
"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"
"MonitorEpisodesModalInfo": "Esta configuração ajustará apenas quais episódios ou temporadas serão monitorados dentro de uma série. Selecionar Nenhum desativará o monitoramento da série"
}

View File

@@ -269,7 +269,7 @@
"AnimeEpisodeFormat": "Формат аниме-эпизода",
"AuthBasic": "Базовый (Всплывающее окно браузера)",
"AuthForm": "Формы (Страница авторизации)",
"Authentication": "Аутентификация",
"Authentication": "Авторизация",
"AuthenticationRequired": "Требуется авторизация",
"BackupIntervalHelpText": "Периодичность автоматического резервного копирования",
"BackupRetentionHelpText": "Автоматические резервные копии старше указанного периода будут автоматически удалены",
@@ -1500,7 +1500,7 @@
"RejectionCount": "Количество отказов",
"Release": "Релиз",
"ReleaseGroup": "Релиз группа",
"ReleaseGroupFootNote": "При необходимости можно управлять обрезкой до максимального количества байтов, включая многоточие (`...`). Поддерживается обрезка как с конца (например, `{Release Group:30}`), так и с начала (например, `{Release Group:-30}`).",
"ReleaseGroupFootNote": "При необходимости можно управлять обрезкой до максимального количества байтов, включая многоточие (`...`). Поддерживается обрезка как с конца (например, `{Release Group:30}`), так и с начала (например, `{Release Group:-30}`).`).",
"ReleaseProfileIndexerHelpText": "Укажите, к какому индексатору применяется профиль",
"ReleaseProfileIndexerHelpTextWarning": "Установка определенного индексатора в профиле релиза приведет к тому, что этот профиль будет применяться только к релизам из этого индексатора.",
"ReleaseProfiles": "Профили релизов",
@@ -1696,7 +1696,7 @@
"MetadataSettings": "Настройки метаданных",
"NotificationsAppriseSettingsNotificationType": "Тип информирования об уведомлении",
"NotificationsEmailSettingsFromAddress": "С адреса",
"NotificationsEmailSettingsUseEncryptionHelpText": "Выбрать режим шифрования: предпочитать шифрование, если оно настроено на сервере; всегда использовать шифрование через SSL (только порт 465) или StartTLS (любой другой порт); никогда не использовать шифрование",
"NotificationsEmailSettingsUseEncryptionHelpText": "Выбрать режим шифрования: предпочитать шифрование, если оно настроено на сервере; всегда использовать шифрование через SSL (только порт 465) или StartTLS (любой другой порт); никогда не использовать шифрование.",
"NotificationsCustomScriptSettingsProviderMessage": "При тестировании будет выполняться сценарий с типом события, установленным на {eventTypeTest}. Убедитесь, что ваш сценарий обрабатывает это правильно",
"NotificationsJoinSettingsApiKeyHelpText": "Ключ API из настроек вашей учетной записи присоединения (нажмите кнопку «Присоединиться к API»).",
"NotificationsGotifySettingsServerHelpText": "URL-адрес сервера Gotify, включая http(s):// и порт, если необходимо",
@@ -2016,7 +2016,7 @@
"Search": "Поиск",
"RestartReloadNote": "Примечание: {appName} автоматически перезапустится и перезагрузит интерфейс пользователя во время процесса восстановления.",
"HealthMessagesInfoBox": "Дополнительную информацию о причине появления этих сообщений о проверке работоспособности можно найти, перейдя по ссылке wiki (значок книги) в конце строки или проверить [журналы]({link}). Если у вас возникли трудности с пониманием этих сообщений, вы можете обратиться в нашу службу поддержки по ссылкам ниже.",
"MaintenanceRelease": "Технический релиз: исправление ошибок и другие улучшения. Подробнее см. в истории коммитов Github",
"MaintenanceRelease": "Технический релиз: исправление ошибок и другие улучшения. Подробнее см. в истории коммитов Github.",
"Space": "Пробел",
"SslCertPasswordHelpText": "Пароль для файла pfx",
"SpecialEpisode": "Спец. эпизод",
@@ -2155,6 +2155,7 @@
"UpdatePath": "Обновить путь",
"UpdateSeriesPath": "Обновить путь до сериала",
"ReleasePush": "Через API",
"DownloadClientUTorrentProviderMessage": "Мы настоятельно советуем не использовать uTorrent, т.к. он известен как программа-шифровальщик и в целом вредоносное ПО.",
"CloneImportList": "Копировать список импорта",
"EpisodesInSeason": "{episodeCount} эпизодов в сезоне",
"DefaultNameCopiedImportList": "{name} - копировать",
@@ -2164,11 +2165,5 @@
"NotificationsAppriseSettingsIncludePosterHelpText": "Добавлять постер в сообщение",
"EpisodeMonitoring": "Отслеживание эпизода",
"MonitorEpisodes": "Отслеживать эпизоды",
"MonitorEpisodesModalInfo": "Эта настройка влияет только на отслеживание эпизодов или сезонов внутри сериала. Выбор ничего приведёт к остановке отслеживания сериала",
"ImportListsSimklSettingsUserListTypeHold": "Оставить",
"UserRejectedExtensions": "Дополнительные запрещенные расширения файлов",
"UserRejectedExtensionsTextsExamples": "Примеры: '.ext, .xyz' или 'ext,xyz'",
"DownloadClientQbittorrentSettingsAddSeriesTags": "Добавлять теги сериалов",
"DownloadClientQbittorrentSettingsAddSeriesTagsHelpText": "Добавлять теги сериалов к новым торрентам, добавляемым в загрузчик (qBittorrent 4.1.0+)",
"UserRejectedExtensionsHelpText": "Список запрещенных расширений файлов, разделенных запятой (так же нужно включить настройку Считать загрузки неуспешными в настройках индексаторов)"
"MonitorEpisodesModalInfo": "Эта настройка влияет только на отслеживание эпизодов или сезонов внутри сериала. Выбор ничего приведёт к остановке отслеживания сериала"
}

View File

@@ -43,6 +43,5 @@
"DownloadStationStatusExtracting": "Packar upp: {progress}%",
"Duplicate": "Dubblett",
"Yesterday": "Igår",
"EditCustomFormat": "Redigera anpassat format",
"AbsoluteEpisodeNumber": "Fullständigt Avsnitt Nummer"
"EditCustomFormat": "Redigera anpassat format"
}

View File

@@ -1791,7 +1791,7 @@
"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}.",
"IndexerSettingsFailDownloads": "İndirmeleri Başarısız Say",
"IndexerSettingsFailDownloads": "Başarısız İndirmeler",
"IndexerSettingsFailDownloadsHelpText": "Tamamlanan indirmeler işlenirken {appName} bu seçili dosya türlerini başarısız indirmeler olarak değerlendirecektir.",
"IndexerSettingsMinimumSeeders": "Minimum Seeder",
"IndexerSettingsRssUrl": "RSS URL",
@@ -2152,6 +2152,7 @@
"QualityDefinitionsSizeNotice": "Boyut kısıtlamaları Kalite Profillerine taşındı",
"NotificationsTelegramSettingsLinkPreview": "Bağlantı Önizlemesi",
"NotificationsTelegramSettingsLinkPreviewHelpText": "Telegram bildiriminde hangi bağlantının önizleneceğini belirler. Devre dışı bırakmak için 'Hiçbiri'ni seçin",
"DownloadClientUTorrentProviderMessage": "uTorrent'in kripto para madenciliği, kötü amaçlı yazılım ve reklam içerme geçmişi vardır, bu nedenle farklı bir istemci seçmenizi önemle tavsiye ederiz.",
"NotificationsPushcutSettingsIncludePoster": "Posteri Dahil Et",
"NotificationsPushcutSettingsMetadataLinks": "Meta Veri Bağlantıları",
"NotificationsPushcutSettingsMetadataLinksHelpText": "Bildirim içeriğine meta verilerin bağlantılarını ekleyin",
@@ -2162,13 +2163,5 @@
"EpisodesInSeason": "Sezondaki {episodeCount} bölüm",
"AutoTaggingSpecificationNetwork": "Ağ(lar)",
"NotificationsAppriseSettingsIncludePoster": "Poster'i ekle",
"NotificationsAppriseSettingsIncludePosterHelpText": "Mesaja poster ekle",
"DownloadClientQbittorrentSettingsAddSeriesTags": "Dizilere Etiket Ekle",
"DownloadClientQbittorrentSettingsAddSeriesTagsHelpText": "İndirme istemcisine (qBittorrent 4.1.0+) eklenen yeni torrentlere dizi etiketleri ekle",
"UserRejectedExtensionsTextsExamples": "Örneğin: '.ext, .xyz' veya 'ext,xyz'",
"MonitorEpisodes": "Bölümleri Takip Et",
"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"
"NotificationsAppriseSettingsIncludePosterHelpText": "Mesaja poster ekle"
}

View File

@@ -2123,6 +2123,7 @@
"Airs": "Ефіри",
"DoneEditingSizes": "Редагування розмірів завершено",
"IndexerSettingsFailDownloads": "Не вдалося завантажити",
"DownloadClientUTorrentProviderMessage": "uTorrent має історію включення криптомайнерів, шкідливого програмного забезпечення та реклами. Ми наполегливо рекомендуємо вибрати інший клієнт.",
"EditSelectedCustomFormats": "Змінити вибрані власні формати",
"EditSizes": "Змінити розміри",
"FailedToFetchSettings": "Не вдалося отримати налаштування",

View File

@@ -26,6 +26,7 @@ namespace NzbDrone.Core.MediaFiles
private readonly IConfigService _configService;
private readonly IEpisodeService _episodeService;
private readonly Logger _logger;
private static readonly DateTime EpochTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
public UpdateEpisodeFileService(IDiskProvider diskProvider,
IConfigService configService,
@@ -46,48 +47,90 @@ namespace NzbDrone.Core.MediaFiles
private bool ChangeFileDate(EpisodeFile episodeFile, Series series, List<Episode> episodes)
{
var episodeFilePath = Path.Combine(series.Path, episodeFile.RelativePath);
var airDateUtc = episodes.First().AirDateUtc;
if (!airDateUtc.HasValue)
switch (_configService.FileDate)
{
return false;
case FileDateType.LocalAirDate:
{
var airDate = episodes.First().AirDate;
var airTime = series.AirTime;
if (airDate.IsNullOrWhiteSpace() || airTime.IsNullOrWhiteSpace())
{
return false;
}
return ChangeFileDateToLocalAirDate(episodeFilePath, airDate, airTime);
}
case FileDateType.UtcAirDate:
{
var airDateUtc = episodes.First().AirDateUtc;
if (!airDateUtc.HasValue)
{
return false;
}
return ChangeFileDateToUtcAirDate(episodeFilePath, airDateUtc.Value);
}
}
return _configService.FileDate switch
{
FileDateType.LocalAirDate =>
ChangeFileDateToLocalDate(episodeFilePath, airDateUtc.Value.ToLocalTime()),
// Intentionally pass UTC as local per user preference
FileDateType.UtcAirDate =>
ChangeFileDateToLocalDate(
episodeFilePath,
DateTime.SpecifyKind(airDateUtc.Value, DateTimeKind.Local)),
_ => false,
};
return false;
}
private bool ChangeFileDateToLocalDate(string filePath, DateTime localDate)
private bool ChangeFileDateToLocalAirDate(string filePath, string fileDate, string fileTime)
{
// FileGetLastWrite returns UTC; convert to local to compare
var oldLastWrite = _diskProvider.FileGetLastWrite(filePath).ToLocalTime();
if (OsInfo.IsNotWindows && localDate.ToUniversalTime() < DateTimeExtensions.EpochTime)
if (DateTime.TryParse(fileDate + ' ' + fileTime, out var airDate))
{
_logger.Debug("Setting date of file to 1970-01-01 as actual airdate is before that time and will not be set properly");
localDate = DateTimeExtensions.EpochTime.ToLocalTime();
// avoiding false +ve checks and set date skewing by not using UTC (Windows)
var oldLastWrite = _diskProvider.FileGetLastWrite(filePath);
if (OsInfo.IsNotWindows && airDate < EpochTime)
{
_logger.Debug("Setting date of file to 1970-01-01 as actual airdate is before that time and will not be set properly");
airDate = EpochTime;
}
if (!DateTime.Equals(airDate.WithoutTicks(), oldLastWrite.WithoutTicks()))
{
try
{
_diskProvider.FileSetLastWriteTime(filePath, airDate);
_logger.Debug("Date of file [{0}] changed from '{1}' to '{2}'", filePath, oldLastWrite, airDate);
return true;
}
catch (Exception ex)
{
_logger.Warn(ex, "Unable to set date of file [" + filePath + "]");
}
}
}
else
{
_logger.Debug("Could not create valid date to change file [{0}]", filePath);
}
if (!DateTime.Equals(localDate.WithoutTicks(), oldLastWrite.WithoutTicks()))
return false;
}
private bool ChangeFileDateToUtcAirDate(string filePath, DateTime airDateUtc)
{
var oldLastWrite = _diskProvider.FileGetLastWrite(filePath);
if (OsInfo.IsNotWindows && airDateUtc < EpochTime)
{
_logger.Debug("Setting date of file to 1970-01-01 as actual airdate is before that time and will not be set properly");
airDateUtc = EpochTime;
}
if (!DateTime.Equals(airDateUtc.WithoutTicks(), oldLastWrite.WithoutTicks()))
{
try
{
// Preserve prior mtime subseconds per https://github.com/Sonarr/Sonarr/issues/7228
var mtime = localDate.WithTicksFrom(oldLastWrite);
_diskProvider.FileSetLastWriteTime(filePath, mtime);
_logger.Debug("Date of file [{0}] changed from '{1}' to '{2}'", filePath, oldLastWrite, mtime);
_diskProvider.FileSetLastWriteTime(filePath, airDateUtc.AddMilliseconds(oldLastWrite.Millisecond));
_logger.Debug("Date of file [{0}] changed from '{1}' to '{2}'", filePath, oldLastWrite, airDateUtc);
return true;
}

View File

@@ -136,7 +136,6 @@ namespace NzbDrone.Core.Notifications.CustomScript
environmentVariables.Add("Sonarr_EpisodeFile_EpisodeAirDatesUtc", string.Join(",", episodeFile.Episodes.Value.Select(e => e.AirDateUtc)));
environmentVariables.Add("Sonarr_EpisodeFile_EpisodeTitles", string.Join("|", episodeFile.Episodes.Value.Select(e => e.Title)));
environmentVariables.Add("Sonarr_EpisodeFile_EpisodeOverviews", string.Join("|", episodeFile.Episodes.Value.Select(e => e.Overview)));
environmentVariables.Add("Sonarr_EpisodeFile_FinaleTypes", string.Join("|", episodeFile.Episodes.Value.Select(e => e.FinaleType)));
environmentVariables.Add("Sonarr_EpisodeFile_Quality", episodeFile.Quality.Quality.Name);
environmentVariables.Add("Sonarr_EpisodeFile_QualityVersion", episodeFile.Quality.Revision.Version.ToString());
environmentVariables.Add("Sonarr_EpisodeFile_ReleaseGroup", episodeFile.ReleaseGroup ?? string.Empty);
@@ -208,7 +207,6 @@ namespace NzbDrone.Core.Notifications.CustomScript
environmentVariables.Add("Sonarr_EpisodeFile_EpisodeAirDatesUtc", string.Join(",", episodes.Select(e => e.AirDateUtc)));
environmentVariables.Add("Sonarr_EpisodeFile_EpisodeTitles", string.Join("|", episodes.Select(e => e.Title)));
environmentVariables.Add("Sonarr_EpisodeFile_EpisodeOverviews", string.Join("|", episodes.Select(e => e.Overview)));
environmentVariables.Add("Sonarr_EpisodeFile_FinaleTypes", string.Join("|", episodes.Select(e => e.FinaleType)));
environmentVariables.Add("Sonarr_EpisodeFile_Qualities", string.Join("|", episodeFiles.Select(f => f.Quality.Quality.Name)));
environmentVariables.Add("Sonarr_EpisodeFile_QualityVersions", string.Join("|", episodeFiles.Select(f => f.Quality.Revision.Version)));
environmentVariables.Add("Sonarr_EpisodeFile_ReleaseGroups", string.Join("|", episodeFiles.Select(f => f.ReleaseGroup)));

View File

@@ -20,7 +20,6 @@ namespace NzbDrone.Core.Notifications.Webhook
AirDateUtc = episode.AirDateUtc;
SeriesId = episode.SeriesId;
TvdbId = episode.TvdbId;
FinaleType = episode.FinaleType;
}
public int Id { get; set; }
@@ -32,6 +31,5 @@ namespace NzbDrone.Core.Notifications.Webhook
public DateTime? AirDateUtc { get; set; }
public int SeriesId { get; set; }
public int TvdbId { get; set; }
public string FinaleType { get; set; }
}
}

View File

@@ -15,8 +15,6 @@ namespace NzbDrone.Core.Notifications.Webhook
public int TvMazeId { get; set; }
public int TmdbId { get; set; }
public string ImdbId { get; set; }
public HashSet<int> MalIds { get; set; }
public HashSet<int> AniListIds { get; set; }
public SeriesTypes Type { get; set; }
public int Year { get; set; }
public List<string> Genres { get; set; }
@@ -38,8 +36,6 @@ namespace NzbDrone.Core.Notifications.Webhook
TvMazeId = series.TvMazeId;
TmdbId = series.TmdbId;
ImdbId = series.ImdbId;
MalIds = series.MalIds;
AniListIds = series.AniListIds;
Type = series.SeriesType;
Year = series.Year;
Genres = series.Genres;

View File

@@ -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(@"(?:\W|_)(?<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)|(?<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,11 +496,6 @@ namespace NzbDrone.Core.Parser
languages.Add(Language.Romansh);
}
if (match.Groups["japanese"].Success)
{
languages.Add(Language.Japanese);
}
if (match.Groups["original"].Success)
{
languages.Add(Language.Original);

View File

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

View File

@@ -1,8 +0,0 @@
using NzbDrone.Common.Messaging;
namespace NzbDrone.Core.Queue
{
public class ObsoleteQueueUpdatedEvent : IEvent
{
}
}

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