mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-03-06 13:30:00 -05:00
Compare commits
2 Commits
v5-queue
...
release-gr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e3b8230bc | ||
|
|
1c3c786335 |
@@ -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
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import React, { createContext, ReactNode, useContext, useMemo } from 'react';
|
||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||
import Queue from 'typings/Queue';
|
||||
|
||||
interface EpisodeDetails {
|
||||
episodeIds: number[];
|
||||
}
|
||||
|
||||
interface SeriesDetails {
|
||||
seriesId: number;
|
||||
}
|
||||
|
||||
interface AllDetails {
|
||||
all: boolean;
|
||||
}
|
||||
|
||||
type QueueDetailsFilter = AllDetails | EpisodeDetails | SeriesDetails;
|
||||
|
||||
interface QueueDetailsProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const QueueDetailsContext = createContext<Queue[] | undefined>(undefined);
|
||||
|
||||
export default function QueueDetailsProvider({
|
||||
children,
|
||||
...filter
|
||||
}: QueueDetailsProps & QueueDetailsFilter) {
|
||||
const { data } = useApiQuery<Queue[]>({
|
||||
path: '/queue/details',
|
||||
queryParams: { ...filter },
|
||||
queryOptions: {
|
||||
enabled: Object.keys(filter).length > 0,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<QueueDetailsContext.Provider value={data}>
|
||||
{children}
|
||||
</QueueDetailsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useQueueItemForEpisode(episodeId: number) {
|
||||
const queue = useContext(QueueDetailsContext);
|
||||
|
||||
return useMemo(() => {
|
||||
return queue?.find((item) => item.episodeIds.includes(episodeId));
|
||||
}, [episodeId, queue]);
|
||||
}
|
||||
|
||||
export function useIsDownloadingEpisodes(episodeIds: number[]) {
|
||||
const queue = useContext(QueueDetailsContext);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!queue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return queue.some((item) =>
|
||||
item.episodeIds?.some((e) => episodeIds.includes(e))
|
||||
);
|
||||
}, [episodeIds, queue]);
|
||||
}
|
||||
|
||||
export interface SeriesQueueDetails {
|
||||
count: number;
|
||||
episodesWithFiles: number;
|
||||
}
|
||||
|
||||
export function useQueueDetailsForSeries(
|
||||
seriesId: number,
|
||||
seasonNumber?: number
|
||||
) {
|
||||
const queue = useContext(QueueDetailsContext);
|
||||
|
||||
return useMemo<SeriesQueueDetails>(() => {
|
||||
if (!queue) {
|
||||
return { count: 0, episodesWithFiles: 0 };
|
||||
}
|
||||
|
||||
return queue.reduce<SeriesQueueDetails>(
|
||||
(acc: SeriesQueueDetails, item) => {
|
||||
if (
|
||||
item.trackedDownloadState === 'imported' ||
|
||||
item.seriesId !== seriesId
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (seasonNumber != null && item.seasonNumber !== seasonNumber) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.count++;
|
||||
|
||||
if (item.episodeHasFile) {
|
||||
acc.episodesWithFiles++;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
count: 0,
|
||||
episodesWithFiles: 0,
|
||||
}
|
||||
);
|
||||
}, [seriesId, seasonNumber, queue]);
|
||||
}
|
||||
|
||||
export const useQueueDetails = () => {
|
||||
return useContext(QueueDetailsContext) ?? [];
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
import React from 'react';
|
||||
import Episode from 'Episode/Episode';
|
||||
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
|
||||
import Series from 'Series/Series';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
interface EpisodeCellContentProps {
|
||||
episodes: Episode[];
|
||||
isFullSeason: boolean;
|
||||
seasonNumber?: number;
|
||||
series?: Series;
|
||||
}
|
||||
|
||||
export default function EpisodeCellContent({
|
||||
episodes,
|
||||
isFullSeason,
|
||||
seasonNumber,
|
||||
series,
|
||||
}: EpisodeCellContentProps) {
|
||||
if (episodes.length === 0) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (isFullSeason && seasonNumber != null) {
|
||||
return translate('SeasonNumberToken', { seasonNumber });
|
||||
}
|
||||
|
||||
if (episodes.length === 1) {
|
||||
const episode = episodes[0];
|
||||
|
||||
return (
|
||||
<SeasonEpisodeNumber
|
||||
seasonNumber={episode.seasonNumber}
|
||||
episodeNumber={episode.episodeNumber}
|
||||
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
|
||||
seriesType={series?.seriesType}
|
||||
alternateTitles={series?.alternateTitles}
|
||||
sceneSeasonNumber={episode.sceneSeasonNumber}
|
||||
sceneEpisodeNumber={episode.sceneEpisodeNumber}
|
||||
sceneAbsoluteEpisodeNumber={episode.sceneAbsoluteEpisodeNumber}
|
||||
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const firstEpisode = episodes[0];
|
||||
const lastEpisode = episodes[episodes.length - 1];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeasonEpisodeNumber
|
||||
seasonNumber={firstEpisode.seasonNumber}
|
||||
episodeNumber={firstEpisode.episodeNumber}
|
||||
absoluteEpisodeNumber={firstEpisode.absoluteEpisodeNumber}
|
||||
seriesType={series?.seriesType}
|
||||
alternateTitles={series?.alternateTitles}
|
||||
sceneSeasonNumber={firstEpisode.sceneSeasonNumber}
|
||||
sceneEpisodeNumber={firstEpisode.sceneEpisodeNumber}
|
||||
sceneAbsoluteEpisodeNumber={firstEpisode.sceneAbsoluteEpisodeNumber}
|
||||
unverifiedSceneNumbering={firstEpisode.unverifiedSceneNumbering}
|
||||
/>
|
||||
{' - '}
|
||||
<SeasonEpisodeNumber
|
||||
seasonNumber={lastEpisode.seasonNumber}
|
||||
episodeNumber={lastEpisode.episodeNumber}
|
||||
absoluteEpisodeNumber={lastEpisode.absoluteEpisodeNumber}
|
||||
seriesType={series?.seriesType}
|
||||
alternateTitles={series?.alternateTitles}
|
||||
sceneSeasonNumber={lastEpisode.sceneSeasonNumber}
|
||||
sceneEpisodeNumber={lastEpisode.sceneEpisodeNumber}
|
||||
sceneAbsoluteEpisodeNumber={lastEpisode.sceneAbsoluteEpisodeNumber}
|
||||
unverifiedSceneNumbering={lastEpisode.unverifiedSceneNumbering}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
.multiple {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.episodeNumber {
|
||||
margin-right: 8px;
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'episodeNumber': string;
|
||||
'multiple': string;
|
||||
'row': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -1,66 +0,0 @@
|
||||
import React from 'react';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import Episode from 'Episode/Episode';
|
||||
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
|
||||
import Series from 'Series/Series';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EpisodeTitleCellContent.css';
|
||||
|
||||
interface EpisodeTitleCellContentProps {
|
||||
episodes: Episode[];
|
||||
series?: Series;
|
||||
}
|
||||
|
||||
export default function EpisodeTitleCellContent({
|
||||
episodes,
|
||||
series,
|
||||
}: EpisodeTitleCellContentProps) {
|
||||
if (episodes.length === 0 || !series) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (episodes.length === 1) {
|
||||
const episode = episodes[0];
|
||||
|
||||
return (
|
||||
<EpisodeTitleLink
|
||||
episodeId={episode.id}
|
||||
seriesId={series.id}
|
||||
episodeTitle={episode.title}
|
||||
episodeEntity="episodes"
|
||||
showOpenSeriesButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
anchor={
|
||||
<span className={styles.multiple}>{translate('MultipleEpisodes')}</span>
|
||||
}
|
||||
title={translate('EpisodeTitles')}
|
||||
body={
|
||||
<>
|
||||
{episodes.map((episode) => {
|
||||
return (
|
||||
<div key={episode.id} className={styles.row}>
|
||||
<div className={styles.episodeNumber}>
|
||||
{episode.episodeNumber}
|
||||
</div>
|
||||
|
||||
<EpisodeTitleLink
|
||||
episodeId={episode.id}
|
||||
seriesId={series.id}
|
||||
episodeTitle={episode.title}
|
||||
episodeEntity="episodes"
|
||||
showOpenSeriesButton={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
}
|
||||
position="right"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import React, {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
@@ -21,15 +22,28 @@ import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||
import TablePager from 'Components/Table/TablePager';
|
||||
import usePaging from 'Components/Table/usePaging';
|
||||
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
|
||||
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
|
||||
import useSelectState from 'Helpers/Hooks/useSelectState';
|
||||
import { align, icons, kinds } from 'Helpers/Props';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
|
||||
import {
|
||||
clearQueue,
|
||||
fetchQueue,
|
||||
gotoQueuePage,
|
||||
grabQueueItems,
|
||||
removeQueueItems,
|
||||
setQueueFilter,
|
||||
setQueueSort,
|
||||
setQueueTableOption,
|
||||
} from 'Store/Actions/queueActions';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import { CheckInputChanged } from 'typings/inputs';
|
||||
import { SelectStateInputProps } from 'typings/props';
|
||||
import QueueItem from 'typings/Queue';
|
||||
import { TableOptionsChangePayload } from 'typings/Table';
|
||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||
import {
|
||||
@@ -40,51 +54,33 @@ import translate from 'Utilities/String/translate';
|
||||
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
||||
import QueueFilterModal from './QueueFilterModal';
|
||||
import QueueOptions from './QueueOptions';
|
||||
import {
|
||||
setQueueOption,
|
||||
setQueueOptions,
|
||||
useQueueOptions,
|
||||
} from './queueOptionsStore';
|
||||
import QueueRow from './QueueRow';
|
||||
import RemoveQueueItemModal from './RemoveQueueItemModal';
|
||||
import useQueueStatus from './Status/useQueueStatus';
|
||||
import useQueue, {
|
||||
useFilters,
|
||||
useGrabQueueItems,
|
||||
useRemoveQueueItems,
|
||||
} from './useQueue';
|
||||
|
||||
const DEFAULT_DATA = {
|
||||
records: [],
|
||||
totalPages: 0,
|
||||
totalRecords: 0,
|
||||
};
|
||||
import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal';
|
||||
import createQueueStatusSelector from './Status/createQueueStatusSelector';
|
||||
|
||||
function Queue() {
|
||||
const requestCurrentPage = useCurrentPage();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
isFetching,
|
||||
isFetched,
|
||||
isLoading,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
columns,
|
||||
selectedFilterKey,
|
||||
filters,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
page,
|
||||
goToPage,
|
||||
refetch,
|
||||
} = useQueue();
|
||||
pageSize,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
isGrabbing,
|
||||
isRemoving,
|
||||
} = useSelector((state: AppState) => state.queue.paged);
|
||||
|
||||
const { records, totalPages = 0, totalRecords } = data ?? DEFAULT_DATA;
|
||||
|
||||
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
|
||||
useQueueOptions();
|
||||
|
||||
const filters = useFilters();
|
||||
|
||||
const { isRemoving, removeQueueItems } = useRemoveQueueItems();
|
||||
const { isGrabbing, grabQueueItems } = useGrabQueueItems();
|
||||
|
||||
const { count } = useQueueStatus();
|
||||
const { count } = useSelector(createQueueStatusSelector());
|
||||
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
|
||||
useSelector(createEpisodesFetchingSelector());
|
||||
const customFilters = useSelector(createCustomFiltersSelector('queue'));
|
||||
@@ -104,46 +100,41 @@ function Queue() {
|
||||
}, [selectedState]);
|
||||
|
||||
const isPendingSelected = useMemo(() => {
|
||||
return records.some((item) => {
|
||||
return items.some((item) => {
|
||||
return selectedIds.indexOf(item.id) > -1 && item.status === 'delay';
|
||||
});
|
||||
}, [records, selectedIds]);
|
||||
}, [items, selectedIds]);
|
||||
|
||||
const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const isRefreshing =
|
||||
isLoading || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
|
||||
isFetching || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
|
||||
const isAllPopulated =
|
||||
isFetched &&
|
||||
(isEpisodesPopulated ||
|
||||
!records.length ||
|
||||
records.every((e) => !e.episodeIds?.length));
|
||||
isPopulated &&
|
||||
(isEpisodesPopulated || !items.length || items.every((e) => !e.episodeId));
|
||||
const hasError = error || episodesError;
|
||||
const selectedCount = selectedIds.length;
|
||||
const disableSelectedActions = selectedCount === 0;
|
||||
|
||||
const handleSelectAllChange = useCallback(
|
||||
({ value }: CheckInputChanged) => {
|
||||
setSelectState({
|
||||
type: value ? 'selectAll' : 'unselectAll',
|
||||
items: records,
|
||||
});
|
||||
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
|
||||
},
|
||||
[records, setSelectState]
|
||||
[items, setSelectState]
|
||||
);
|
||||
|
||||
const handleSelectedChange = useCallback(
|
||||
({ id, value, shiftKey = false }: SelectStateInputProps) => {
|
||||
setSelectState({
|
||||
type: 'toggleSelected',
|
||||
items: records,
|
||||
items,
|
||||
id,
|
||||
isSelected: value,
|
||||
shiftKey,
|
||||
});
|
||||
},
|
||||
[records, setSelectState]
|
||||
[items, setSelectState]
|
||||
);
|
||||
|
||||
const handleRefreshPress = useCallback(() => {
|
||||
@@ -159,60 +150,93 @@ function Queue() {
|
||||
}, []);
|
||||
|
||||
const handleGrabSelectedPress = useCallback(() => {
|
||||
grabQueueItems({ ids: selectedIds });
|
||||
}, [selectedIds, grabQueueItems]);
|
||||
dispatch(grabQueueItems({ ids: selectedIds }));
|
||||
}, [selectedIds, dispatch]);
|
||||
|
||||
const handleRemoveSelectedPress = useCallback(() => {
|
||||
shouldBlockRefresh.current = true;
|
||||
setIsConfirmRemoveModalOpen(true);
|
||||
}, [setIsConfirmRemoveModalOpen]);
|
||||
|
||||
const handleRemoveSelectedConfirmed = useCallback(() => {
|
||||
shouldBlockRefresh.current = false;
|
||||
removeQueueItems({ ids: selectedIds });
|
||||
setIsConfirmRemoveModalOpen(false);
|
||||
}, [selectedIds, setIsConfirmRemoveModalOpen, removeQueueItems]);
|
||||
const handleRemoveSelectedConfirmed = useCallback(
|
||||
(payload: RemovePressProps) => {
|
||||
shouldBlockRefresh.current = false;
|
||||
dispatch(removeQueueItems({ ids: selectedIds, ...payload }));
|
||||
setIsConfirmRemoveModalOpen(false);
|
||||
},
|
||||
[selectedIds, setIsConfirmRemoveModalOpen, dispatch]
|
||||
);
|
||||
|
||||
const handleConfirmRemoveModalClose = useCallback(() => {
|
||||
shouldBlockRefresh.current = false;
|
||||
setIsConfirmRemoveModalOpen(false);
|
||||
}, [setIsConfirmRemoveModalOpen]);
|
||||
|
||||
const {
|
||||
handleFirstPagePress,
|
||||
handlePreviousPagePress,
|
||||
handleNextPagePress,
|
||||
handleLastPagePress,
|
||||
handlePageSelect,
|
||||
} = usePaging({
|
||||
page,
|
||||
totalPages,
|
||||
gotoPage: gotoQueuePage,
|
||||
});
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(selectedFilterKey: string | number) => {
|
||||
setQueueOption('selectedFilterKey', selectedFilterKey);
|
||||
dispatch(setQueueFilter({ selectedFilterKey }));
|
||||
},
|
||||
[]
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleSortPress = useCallback((sortKey: string) => {
|
||||
setQueueOption('sortKey', sortKey);
|
||||
}, []);
|
||||
const handleSortPress = useCallback(
|
||||
(sortKey: string) => {
|
||||
dispatch(setQueueSort({ sortKey }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleTableOptionChange = useCallback(
|
||||
(payload: TableOptionsChangePayload) => {
|
||||
setQueueOptions(payload);
|
||||
dispatch(setQueueTableOption(payload));
|
||||
|
||||
if (payload.pageSize) {
|
||||
goToPage(1);
|
||||
dispatch(gotoQueuePage({ page: 1 }));
|
||||
}
|
||||
},
|
||||
[goToPage]
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const episodeIds = selectUniqueIds(records, 'episodeIds');
|
||||
if (requestCurrentPage) {
|
||||
dispatch(fetchQueue());
|
||||
} else {
|
||||
dispatch(gotoQueuePage({ page: 1 }));
|
||||
}
|
||||
|
||||
return () => {
|
||||
dispatch(clearQueue());
|
||||
};
|
||||
}, [requestCurrentPage, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const episodeIds = selectUniqueIds<QueueItem, number | undefined>(
|
||||
items,
|
||||
'episodeId'
|
||||
);
|
||||
|
||||
if (episodeIds.length) {
|
||||
dispatch(fetchEpisodes({ episodeIds }));
|
||||
} else {
|
||||
dispatch(clearEpisodes());
|
||||
}
|
||||
}, [records, dispatch]);
|
||||
}, [items, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const repopulate = () => {
|
||||
refetch();
|
||||
dispatch(fetchQueue());
|
||||
};
|
||||
|
||||
registerPagePopulator(repopulate);
|
||||
@@ -220,7 +244,7 @@ function Queue() {
|
||||
return () => {
|
||||
unregisterPagePopulator(repopulate);
|
||||
};
|
||||
}, [refetch]);
|
||||
}, [dispatch]);
|
||||
|
||||
if (!shouldBlockRefresh.current) {
|
||||
currentQueue.current = (
|
||||
@@ -231,7 +255,7 @@ function Queue() {
|
||||
<Alert kind={kinds.DANGER}>{translate('QueueLoadError')}</Alert>
|
||||
) : null}
|
||||
|
||||
{isAllPopulated && !hasError && !records.length ? (
|
||||
{isAllPopulated && !hasError && !items.length ? (
|
||||
<Alert kind={kinds.INFO}>
|
||||
{selectedFilterKey !== 'all' && count > 0
|
||||
? translate('QueueFilterHasNoItems')
|
||||
@@ -239,7 +263,7 @@ function Queue() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{isAllPopulated && !hasError && !!records.length ? (
|
||||
{isAllPopulated && !hasError && !!items.length ? (
|
||||
<div>
|
||||
<Table
|
||||
selectAll={true}
|
||||
@@ -255,10 +279,11 @@ function Queue() {
|
||||
onSortPress={handleSortPress}
|
||||
>
|
||||
<TableBody>
|
||||
{records.map((item) => {
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<QueueRow
|
||||
key={item.id}
|
||||
episodeId={item.episodeId}
|
||||
isSelected={selectedState[item.id]}
|
||||
columns={columns}
|
||||
{...item}
|
||||
@@ -277,7 +302,11 @@ function Queue() {
|
||||
totalPages={totalPages}
|
||||
totalRecords={totalRecords}
|
||||
isFetching={isFetching}
|
||||
onPageSelect={goToPage}
|
||||
onFirstPagePress={handleFirstPagePress}
|
||||
onPreviousPagePress={handlePreviousPagePress}
|
||||
onNextPagePress={handleNextPagePress}
|
||||
onLastPagePress={handleLastPagePress}
|
||||
onPageSelect={handlePageSelect}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -348,7 +377,7 @@ function Queue() {
|
||||
canChangeCategory={
|
||||
isConfirmRemoveModalOpen &&
|
||||
selectedIds.every((id) => {
|
||||
const item = records.find((i) => i.id === id);
|
||||
const item = items.find((i) => i.id === id);
|
||||
|
||||
return !!(item && item.downloadClientHasPostImportCategory);
|
||||
})
|
||||
@@ -356,7 +385,7 @@ function Queue() {
|
||||
canIgnore={
|
||||
isConfirmRemoveModalOpen &&
|
||||
selectedIds.every((id) => {
|
||||
const item = records.find((i) => i.id === id);
|
||||
const item = items.find((i) => i.id === id);
|
||||
|
||||
return !!(item && item.seriesId && item.episodeId);
|
||||
})
|
||||
@@ -364,7 +393,7 @@ function Queue() {
|
||||
isPending={
|
||||
isConfirmRemoveModalOpen &&
|
||||
selectedIds.every((id) => {
|
||||
const item = records.find((i) => i.id === id);
|
||||
const item = items.find((i) => i.id === id);
|
||||
|
||||
if (!item) {
|
||||
return false;
|
||||
|
||||
@@ -14,7 +14,7 @@ import styles from './QueueDetails.css';
|
||||
interface QueueDetailsProps {
|
||||
title: string;
|
||||
size: number;
|
||||
sizeLeft: number;
|
||||
sizeleft: number;
|
||||
estimatedCompletionTime?: string;
|
||||
status: string;
|
||||
trackedDownloadState?: QueueTrackedDownloadState;
|
||||
@@ -28,7 +28,7 @@ function QueueDetails(props: QueueDetailsProps) {
|
||||
const {
|
||||
title,
|
||||
size,
|
||||
sizeLeft,
|
||||
sizeleft,
|
||||
status,
|
||||
trackedDownloadState = 'downloading',
|
||||
trackedDownloadStatus = 'ok',
|
||||
@@ -37,7 +37,7 @@ function QueueDetails(props: QueueDetailsProps) {
|
||||
progressBar,
|
||||
} = props;
|
||||
|
||||
const progress = 100 - (sizeLeft / size) * 100;
|
||||
const progress = 100 - (sizeleft / size) * 100;
|
||||
const isDownloading = status === 'downloading';
|
||||
const isPaused = status === 'paused';
|
||||
const hasWarning = trackedDownloadStatus === 'warning';
|
||||
|
||||
@@ -1,26 +1,49 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
|
||||
import { setQueueOption } from './queueOptionsStore';
|
||||
import useQueue, { FILTER_BUILDER } from './useQueue';
|
||||
import { setQueueFilter } from 'Store/Actions/queueActions';
|
||||
|
||||
function createQueueSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.queue.paged.items,
|
||||
(queueItems) => {
|
||||
return queueItems;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createFilterBuilderPropsSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.queue.paged.filterBuilderProps,
|
||||
(filterBuilderProps) => {
|
||||
return filterBuilderProps;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
type QueueFilterModalProps = FilterModalProps<History>;
|
||||
|
||||
export default function QueueFilterModal(props: QueueFilterModalProps) {
|
||||
const { data } = useQueue();
|
||||
const sectionItems = useSelector(createQueueSelector());
|
||||
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
|
||||
const customFilterType = 'queue';
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const dispatchSetFilter = useCallback(
|
||||
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
|
||||
setQueueOption('selectedFilterKey', selectedFilterKey);
|
||||
(payload: unknown) => {
|
||||
dispatch(setQueueFilter(payload));
|
||||
},
|
||||
[]
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterModal
|
||||
{...props}
|
||||
sectionItems={data?.records ?? []}
|
||||
filterBuilderProps={FILTER_BUILDER}
|
||||
sectionItems={sectionItems}
|
||||
filterBuilderProps={filterBuilderProps}
|
||||
customFilterType={customFilterType}
|
||||
dispatchSetFilter={dispatchSetFilter}
|
||||
/>
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import { OptionChanged } from 'Helpers/Hooks/useOptionsStore';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import { gotoQueuePage, setQueueOption } from 'Store/Actions/queueActions';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import {
|
||||
QueueOptions as QueueOptionsType,
|
||||
setQueueOption,
|
||||
useQueueOption,
|
||||
} from './queueOptionsStore';
|
||||
import useQueue from './useQueue';
|
||||
|
||||
function QueueOptions() {
|
||||
const includeUnknownSeriesItems = useQueueOption('includeUnknownSeriesItems');
|
||||
const { goToPage } = useQueue();
|
||||
const dispatch = useDispatch();
|
||||
const { includeUnknownSeriesItems } = useSelector(
|
||||
(state: AppState) => state.queue.options
|
||||
);
|
||||
|
||||
const handleOptionChange = useCallback(
|
||||
({ name, value }: OptionChanged<QueueOptionsType>) => {
|
||||
setQueueOption(name, value);
|
||||
({ name, value }: InputChanged<boolean>) => {
|
||||
dispatch(
|
||||
setQueueOption({
|
||||
[name]: value,
|
||||
})
|
||||
);
|
||||
|
||||
if (name === 'includeUnknownSeriesItems') {
|
||||
goToPage(1);
|
||||
dispatch(gotoQueuePage({ page: 1 }));
|
||||
}
|
||||
},
|
||||
[goToPage]
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -36,7 +39,6 @@ function QueueOptions() {
|
||||
name="includeUnknownSeriesItems"
|
||||
value={includeUnknownSeriesItems}
|
||||
helpText={translate('ShowUnknownSeriesItemsHelpText')}
|
||||
// @ts-expect-error - The typing for inputs needs more work
|
||||
onChange={handleOptionChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
|
||||
import { Error } from 'App/State/AppSectionState';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||
import ProgressBar from 'Components/ProgressBar';
|
||||
@@ -14,13 +15,16 @@ import DownloadProtocol from 'DownloadClient/DownloadProtocol';
|
||||
import EpisodeFormats from 'Episode/EpisodeFormats';
|
||||
import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
||||
import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||
import useEpisodes from 'Episode/useEpisodes';
|
||||
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
|
||||
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
|
||||
import useEpisode from 'Episode/useEpisode';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||
import Language from 'Language/Language';
|
||||
import { QualityModel } from 'Quality/Quality';
|
||||
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
import { SelectStateInputProps } from 'typings/props';
|
||||
@@ -32,18 +36,15 @@ import {
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EpisodeCellContent from './EpisodeCellContent';
|
||||
import EpisodeTitleCellContent from './EpisodeTitleCellContent';
|
||||
import QueueStatusCell from './QueueStatusCell';
|
||||
import RemoveQueueItemModal from './RemoveQueueItemModal';
|
||||
import TimeLeftCell from './TimeLeftCell';
|
||||
import { useGrabQueueItem, useRemoveQueueItem } from './useQueue';
|
||||
import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal';
|
||||
import TimeleftCell from './TimeleftCell';
|
||||
import styles from './QueueRow.css';
|
||||
|
||||
interface QueueRowProps {
|
||||
id: number;
|
||||
seriesId?: number;
|
||||
episodeIds: number[];
|
||||
episodeId?: number;
|
||||
downloadId?: string;
|
||||
title: string;
|
||||
status: string;
|
||||
@@ -57,16 +58,16 @@ interface QueueRowProps {
|
||||
customFormatScore: number;
|
||||
protocol: DownloadProtocol;
|
||||
indexer?: string;
|
||||
isFullSeason: boolean;
|
||||
seasonNumbers: number[];
|
||||
outputPath?: string;
|
||||
downloadClient?: string;
|
||||
downloadClientHasPostImportCategory?: boolean;
|
||||
estimatedCompletionTime?: string;
|
||||
added?: string;
|
||||
timeLeft?: string;
|
||||
timeleft?: string;
|
||||
size: number;
|
||||
sizeLeft: number;
|
||||
sizeleft: number;
|
||||
isGrabbing?: boolean;
|
||||
grabError?: Error;
|
||||
isRemoving?: boolean;
|
||||
isSelected?: boolean;
|
||||
columns: Column[];
|
||||
@@ -78,7 +79,7 @@ function QueueRow(props: QueueRowProps) {
|
||||
const {
|
||||
id,
|
||||
seriesId,
|
||||
episodeIds,
|
||||
episodeId,
|
||||
downloadId,
|
||||
title,
|
||||
status,
|
||||
@@ -96,25 +97,25 @@ function QueueRow(props: QueueRowProps) {
|
||||
downloadClient,
|
||||
downloadClientHasPostImportCategory,
|
||||
estimatedCompletionTime,
|
||||
isFullSeason,
|
||||
seasonNumbers,
|
||||
added,
|
||||
timeLeft,
|
||||
timeleft,
|
||||
size,
|
||||
sizeLeft,
|
||||
sizeleft,
|
||||
isGrabbing = false,
|
||||
grabError,
|
||||
isRemoving = false,
|
||||
isSelected,
|
||||
columns,
|
||||
onSelectedChange,
|
||||
onQueueRowModalOpenOrClose,
|
||||
} = props;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const series = useSeries(seriesId);
|
||||
const episodes = useEpisodes(episodeIds, 'episodes');
|
||||
const episode = useEpisode(episodeId, 'episodes');
|
||||
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
const { removeQueueItem, isRemoving } = useRemoveQueueItem(id);
|
||||
const { grabQueueItem, isGrabbing, grabError } = useGrabQueueItem(id);
|
||||
|
||||
const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] =
|
||||
useState(false);
|
||||
@@ -123,8 +124,8 @@ function QueueRow(props: QueueRowProps) {
|
||||
useState(false);
|
||||
|
||||
const handleGrabPress = useCallback(() => {
|
||||
grabQueueItem();
|
||||
}, [grabQueueItem]);
|
||||
dispatch(grabQueueItem({ id }));
|
||||
}, [id, dispatch]);
|
||||
|
||||
const handleInteractiveImportPress = useCallback(() => {
|
||||
onQueueRowModalOpenOrClose(true);
|
||||
@@ -141,22 +142,21 @@ function QueueRow(props: QueueRowProps) {
|
||||
setIsRemoveQueueItemModalOpen(true);
|
||||
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
|
||||
|
||||
const handleRemoveQueueItemModalConfirmed = useCallback(() => {
|
||||
onQueueRowModalOpenOrClose(false);
|
||||
removeQueueItem();
|
||||
setIsRemoveQueueItemModalOpen(false);
|
||||
}, [
|
||||
setIsRemoveQueueItemModalOpen,
|
||||
removeQueueItem,
|
||||
onQueueRowModalOpenOrClose,
|
||||
]);
|
||||
const handleRemoveQueueItemModalConfirmed = useCallback(
|
||||
(payload: RemovePressProps) => {
|
||||
onQueueRowModalOpenOrClose(false);
|
||||
dispatch(removeQueueItem({ id, ...payload }));
|
||||
setIsRemoveQueueItemModalOpen(false);
|
||||
},
|
||||
[id, setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose, dispatch]
|
||||
);
|
||||
|
||||
const handleRemoveQueueItemModalClose = useCallback(() => {
|
||||
onQueueRowModalOpenOrClose(false);
|
||||
setIsRemoveQueueItemModalOpen(false);
|
||||
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
|
||||
|
||||
const progress = 100 - (sizeLeft / size) * 100;
|
||||
const progress = 100 - (sizeleft / size) * 100;
|
||||
const showInteractiveImport =
|
||||
status === 'completed' && trackedDownloadStatus === 'warning';
|
||||
const isPending =
|
||||
@@ -209,12 +209,23 @@ function QueueRow(props: QueueRowProps) {
|
||||
if (name === 'episode') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
<EpisodeCellContent
|
||||
episodes={episodes}
|
||||
isFullSeason={isFullSeason}
|
||||
seasonNumber={seasonNumbers[0]}
|
||||
series={series}
|
||||
/>
|
||||
{episode ? (
|
||||
<SeasonEpisodeNumber
|
||||
seasonNumber={episode.seasonNumber}
|
||||
episodeNumber={episode.episodeNumber}
|
||||
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
|
||||
seriesType={series?.seriesType}
|
||||
alternateTitles={series?.alternateTitles}
|
||||
sceneSeasonNumber={episode.sceneSeasonNumber}
|
||||
sceneEpisodeNumber={episode.sceneEpisodeNumber}
|
||||
sceneAbsoluteEpisodeNumber={
|
||||
episode.sceneAbsoluteEpisodeNumber
|
||||
}
|
||||
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
|
||||
/>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
@@ -222,37 +233,27 @@ function QueueRow(props: QueueRowProps) {
|
||||
if (name === 'episodes.title') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
<EpisodeTitleCellContent episodes={episodes} series={series} />
|
||||
{series && episode ? (
|
||||
<EpisodeTitleLink
|
||||
episodeId={episode.id}
|
||||
seriesId={series.id}
|
||||
episodeTitle={episode.title}
|
||||
episodeEntity="episodes"
|
||||
showOpenSeriesButton={true}
|
||||
/>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'episodes.airDateUtc') {
|
||||
if (episodes.length === 0) {
|
||||
return <TableRowCell key={name}>-</TableRowCell>;
|
||||
if (episode) {
|
||||
return <RelativeDateCell key={name} date={episode.airDateUtc} />;
|
||||
}
|
||||
|
||||
if (episodes.length === 1) {
|
||||
return (
|
||||
<RelativeDateCell key={name} date={episodes[0].airDateUtc} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
<RelativeDateCell
|
||||
key={name}
|
||||
component="span"
|
||||
date={episodes[0].airDateUtc}
|
||||
/>
|
||||
{' - '}
|
||||
<RelativeDateCell
|
||||
key={name}
|
||||
component="span"
|
||||
date={episodes[episodes.length - 1].airDateUtc}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
return <TableRowCell key={name}>-</TableRowCell>;
|
||||
}
|
||||
|
||||
if (name === 'languages') {
|
||||
@@ -324,13 +325,13 @@ function QueueRow(props: QueueRowProps) {
|
||||
|
||||
if (name === 'estimatedCompletionTime') {
|
||||
return (
|
||||
<TimeLeftCell
|
||||
<TimeleftCell
|
||||
key={name}
|
||||
status={status}
|
||||
estimatedCompletionTime={estimatedCompletionTime}
|
||||
timeLeft={timeLeft}
|
||||
timeleft={timeleft}
|
||||
size={size}
|
||||
sizeLeft={sizeLeft}
|
||||
sizeleft={sizeleft}
|
||||
showRelativeDates={showRelativeDates}
|
||||
shortDateFormat={shortDateFormat}
|
||||
timeFormat={timeFormat}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
@@ -9,16 +11,19 @@ import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { OptionChanged } from 'Helpers/Hooks/useOptionsStore';
|
||||
import { inputTypes, kinds, sizes } from 'Helpers/Props';
|
||||
import { setQueueRemovalOption } from 'Store/Actions/queueActions';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import {
|
||||
QueueOptions,
|
||||
setQueueOption,
|
||||
useQueueOption,
|
||||
} from './queueOptionsStore';
|
||||
import styles from './RemoveQueueItemModal.css';
|
||||
|
||||
export interface RemovePressProps {
|
||||
remove: boolean;
|
||||
changeCategory: boolean;
|
||||
blocklist: boolean;
|
||||
skipRedownload: boolean;
|
||||
}
|
||||
|
||||
interface RemoveQueueItemModalProps {
|
||||
isOpen: boolean;
|
||||
sourceTitle?: string;
|
||||
@@ -26,7 +31,7 @@ interface RemoveQueueItemModalProps {
|
||||
canIgnore: boolean;
|
||||
isPending: boolean;
|
||||
selectedCount?: number;
|
||||
onRemovePress(): void;
|
||||
onRemovePress(props: RemovePressProps): void;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
@@ -42,8 +47,13 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
onModalClose,
|
||||
} = props;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const multipleSelected = selectedCount && selectedCount > 1;
|
||||
const { removalMethod, blocklistMethod } = useQueueOption('removalOptions');
|
||||
|
||||
const { removalMethod, blocklistMethod } = useSelector(
|
||||
(state: AppState) => state.queue.removalOptions
|
||||
);
|
||||
|
||||
const { title, message } = useMemo(() => {
|
||||
if (!selectedCount) {
|
||||
@@ -128,19 +138,20 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
}, [isPending, multipleSelected]);
|
||||
|
||||
const handleRemovalOptionInputChange = useCallback(
|
||||
({ name, value }: OptionChanged<QueueOptions['removalOptions']>) => {
|
||||
setQueueOption('removalOptions', {
|
||||
removalMethod,
|
||||
blocklistMethod,
|
||||
[name]: value,
|
||||
});
|
||||
({ name, value }: InputChanged) => {
|
||||
dispatch(setQueueRemovalOption({ [name]: value }));
|
||||
},
|
||||
[removalMethod, blocklistMethod]
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleConfirmRemove = useCallback(() => {
|
||||
onRemovePress();
|
||||
}, [onRemovePress]);
|
||||
onRemovePress({
|
||||
remove: removalMethod === 'removeFromClient',
|
||||
changeCategory: removalMethod === 'changeCategory',
|
||||
blocklist: blocklistMethod !== 'doNotBlocklist',
|
||||
skipRedownload: blocklistMethod === 'blocklistOnly',
|
||||
});
|
||||
}, [removalMethod, blocklistMethod, onRemovePress]);
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
onModalClose();
|
||||
@@ -167,7 +178,6 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
helpTextWarning={translate(
|
||||
'RemoveQueueItemRemovalMethodHelpTextWarning'
|
||||
)}
|
||||
// @ts-expect-error - The typing for inputs needs more work
|
||||
onChange={handleRemovalOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
@@ -186,7 +196,6 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
value={blocklistMethod}
|
||||
values={blocklistMethodOptions}
|
||||
helpText={translate('BlocklistReleaseHelpText')}
|
||||
// @ts-expect-error - The typing for inputs needs more work
|
||||
onChange={handleRemovalOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
|
||||
import useQueueStatus from './useQueueStatus';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { fetchQueueStatus } from 'Store/Actions/queueActions';
|
||||
import createQueueStatusSelector from './createQueueStatusSelector';
|
||||
|
||||
function QueueStatus() {
|
||||
const { errors, warnings, count } = useQueueStatus();
|
||||
const dispatch = useDispatch();
|
||||
const { isConnected, isReconnecting } = useSelector(
|
||||
(state: AppState) => state.app
|
||||
);
|
||||
const { isPopulated, count, errors, warnings } = useSelector(
|
||||
createQueueStatusSelector()
|
||||
);
|
||||
|
||||
const wasReconnecting = usePrevious(isReconnecting);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPopulated) {
|
||||
dispatch(fetchQueueStatus());
|
||||
}
|
||||
}, [isPopulated, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected && wasReconnecting) {
|
||||
dispatch(fetchQueueStatus());
|
||||
}
|
||||
}, [isConnected, wasReconnecting, dispatch]);
|
||||
|
||||
return (
|
||||
<PageSidebarStatus count={count} errors={errors} warnings={warnings} />
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
|
||||
function createQueueStatusSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.queue.status.isPopulated,
|
||||
(state: AppState) => state.queue.status.item,
|
||||
(state: AppState) => state.queue.options.includeUnknownSeriesItems,
|
||||
(isPopulated, status, includeUnknownSeriesItems) => {
|
||||
const {
|
||||
errors,
|
||||
warnings,
|
||||
unknownErrors,
|
||||
unknownWarnings,
|
||||
count,
|
||||
totalCount,
|
||||
} = status;
|
||||
|
||||
return {
|
||||
...status,
|
||||
isPopulated,
|
||||
count: includeUnknownSeriesItems ? totalCount : count,
|
||||
errors: includeUnknownSeriesItems ? errors || unknownErrors : errors,
|
||||
warnings: includeUnknownSeriesItems
|
||||
? warnings || unknownWarnings
|
||||
: warnings,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default createQueueStatusSelector;
|
||||
@@ -1,54 +0,0 @@
|
||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||
import { useQueueOption } from '../queueOptionsStore';
|
||||
|
||||
export interface QueueStatus {
|
||||
totalCount: number;
|
||||
count: number;
|
||||
unknownCount: number;
|
||||
errors: boolean;
|
||||
warnings: boolean;
|
||||
unknownErrors: boolean;
|
||||
unknownWarnings: boolean;
|
||||
}
|
||||
|
||||
export default function useQueueStatus() {
|
||||
const includeUnknownSeriesItems = useQueueOption('includeUnknownSeriesItems');
|
||||
|
||||
const { data } = useApiQuery<QueueStatus>({
|
||||
path: '/queue/status',
|
||||
queryParams: {
|
||||
includeUnknownSeriesItems,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
count: 0,
|
||||
errors: false,
|
||||
warnings: false,
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
errors,
|
||||
warnings,
|
||||
unknownErrors,
|
||||
unknownWarnings,
|
||||
count,
|
||||
totalCount,
|
||||
} = data;
|
||||
|
||||
if (includeUnknownSeriesItems) {
|
||||
return {
|
||||
count: totalCount,
|
||||
errors: errors || unknownErrors,
|
||||
warnings: warnings || unknownWarnings,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
count,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
.timeLeft {
|
||||
.timeleft {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 100px;
|
||||
@@ -1,7 +1,7 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'timeLeft': string;
|
||||
'timeleft': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -8,26 +8,26 @@ import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
|
||||
import getRelativeDate from 'Utilities/Date/getRelativeDate';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './TimeLeftCell.css';
|
||||
import styles from './TimeleftCell.css';
|
||||
|
||||
interface TimeLeftCellProps {
|
||||
interface TimeleftCellProps {
|
||||
estimatedCompletionTime?: string;
|
||||
timeLeft?: string;
|
||||
timeleft?: string;
|
||||
status: string;
|
||||
size: number;
|
||||
sizeLeft: number;
|
||||
sizeleft: number;
|
||||
showRelativeDates: boolean;
|
||||
shortDateFormat: string;
|
||||
timeFormat: string;
|
||||
}
|
||||
|
||||
function TimeLeftCell(props: TimeLeftCellProps) {
|
||||
function TimeleftCell(props: TimeleftCellProps) {
|
||||
const {
|
||||
estimatedCompletionTime,
|
||||
timeLeft,
|
||||
timeleft,
|
||||
status,
|
||||
size,
|
||||
sizeLeft,
|
||||
sizeleft,
|
||||
showRelativeDates,
|
||||
shortDateFormat,
|
||||
timeFormat,
|
||||
@@ -44,7 +44,7 @@ function TimeLeftCell(props: TimeLeftCellProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<TableRowCell className={styles.timeLeft}>
|
||||
<TableRowCell className={styles.timeleft}>
|
||||
<Tooltip
|
||||
anchor={<Icon name={icons.INFO} />}
|
||||
tooltip={translate('DelayingDownloadUntil', { date, time })}
|
||||
@@ -66,7 +66,7 @@ function TimeLeftCell(props: TimeLeftCellProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<TableRowCell className={styles.timeLeft}>
|
||||
<TableRowCell className={styles.timeleft}>
|
||||
<Tooltip
|
||||
anchor={<Icon name={icons.INFO} />}
|
||||
tooltip={translate('RetryingDownloadOn', { date, time })}
|
||||
@@ -77,21 +77,21 @@ function TimeLeftCell(props: TimeLeftCellProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!timeLeft || status === 'completed' || status === 'failed') {
|
||||
return <TableRowCell className={styles.timeLeft}>-</TableRowCell>;
|
||||
if (!timeleft || status === 'completed' || status === 'failed') {
|
||||
return <TableRowCell className={styles.timeleft}>-</TableRowCell>;
|
||||
}
|
||||
|
||||
const totalSize = formatBytes(size);
|
||||
const remainingSize = formatBytes(sizeLeft);
|
||||
const remainingSize = formatBytes(sizeleft);
|
||||
|
||||
return (
|
||||
<TableRowCell
|
||||
className={styles.timeLeft}
|
||||
className={styles.timeleft}
|
||||
title={`${remainingSize} / ${totalSize}`}
|
||||
>
|
||||
{formatTimeSpan(timeLeft)}
|
||||
{formatTimeSpan(timeleft)}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimeLeftCell;
|
||||
export default TimeleftCell;
|
||||
@@ -1,164 +0,0 @@
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Column from 'Components/Table/Column';
|
||||
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
interface QueueRemovalOptions {
|
||||
removalMethod: 'changeCategory' | 'ignore' | 'removeFromClient';
|
||||
blocklistMethod: 'blocklistAndSearch' | 'blocklistOnly' | 'doNotBlocklist';
|
||||
}
|
||||
|
||||
export interface QueueOptions {
|
||||
includeUnknownSeriesItems: boolean;
|
||||
pageSize: number;
|
||||
selectedFilterKey: string | number;
|
||||
sortKey: string;
|
||||
sortDirection: SortDirection;
|
||||
columns: Column[];
|
||||
removalOptions: QueueRemovalOptions;
|
||||
}
|
||||
|
||||
const { useOptions, useOption, setOptions, setOption } =
|
||||
createOptionsStore<QueueOptions>('queue_options', () => {
|
||||
return {
|
||||
includeUnknownSeriesItems: true,
|
||||
pageSize: 20,
|
||||
selectedFilterKey: 'all',
|
||||
sortKey: 'time',
|
||||
sortDirection: 'descending',
|
||||
columns: [
|
||||
{
|
||||
name: 'status',
|
||||
label: '',
|
||||
columnLabel: () => translate('Status'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
isModifiable: false,
|
||||
},
|
||||
{
|
||||
name: 'series.sortTitle',
|
||||
label: () => translate('Series'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'episode',
|
||||
label: () => translate('EpisodeMaybePlural'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'episodes.title',
|
||||
label: () => translate('EpisodeTitleMaybePlural'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'episodes.airDateUtc',
|
||||
label: () => translate('EpisodeAirDate'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
label: () => translate('Languages'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: () => translate('Quality'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'customFormats',
|
||||
label: () => translate('Formats'),
|
||||
isSortable: false,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'customFormatScore',
|
||||
columnLabel: () => translate('CustomFormatScore'),
|
||||
label: React.createElement(Icon, {
|
||||
name: icons.SCORE,
|
||||
title: () => translate('CustomFormatScore'),
|
||||
}),
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'protocol',
|
||||
label: () => translate('Protocol'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'indexer',
|
||||
label: () => translate('Indexer'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'downloadClient',
|
||||
label: () => translate('DownloadClient'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
label: () => translate('ReleaseTitle'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'size',
|
||||
label: () => translate('Size'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'outputPath',
|
||||
label: () => translate('OutputPath'),
|
||||
isSortable: false,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'estimatedCompletionTime',
|
||||
label: () => translate('TimeLeft'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'added',
|
||||
label: () => translate('Added'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'progress',
|
||||
label: () => translate('Progress'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
label: '',
|
||||
columnLabel: () => translate('Actions'),
|
||||
isVisible: true,
|
||||
isModifiable: false,
|
||||
},
|
||||
],
|
||||
removalOptions: {
|
||||
removalMethod: 'removeFromClient',
|
||||
blocklistMethod: 'doNotBlocklist',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const useQueueOptions = useOptions;
|
||||
export const setQueueOptions = setOptions;
|
||||
export const useQueueOption = useOption;
|
||||
export const setQueueOption = setOption;
|
||||
@@ -1,210 +0,0 @@
|
||||
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { CustomFilter, Filter, FilterBuilderProp } from 'App/State/AppState';
|
||||
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||
import usePage from 'Helpers/Hooks/usePage';
|
||||
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
|
||||
import { filterBuilderValueTypes } from 'Helpers/Props';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import Queue from 'typings/Queue';
|
||||
import getQueryString from 'Utilities/Fetch/getQueryString';
|
||||
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { useQueueOptions } from './queueOptionsStore';
|
||||
|
||||
interface BulkQueueData {
|
||||
ids: number[];
|
||||
}
|
||||
|
||||
export const FILTERS: Filter[] = [
|
||||
{
|
||||
key: 'all',
|
||||
label: () => translate('All'),
|
||||
filters: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const FILTER_BUILDER: FilterBuilderProp<Queue>[] = [
|
||||
{
|
||||
name: 'seriesIds',
|
||||
label: () => translate('Series'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.SERIES,
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: () => translate('Quality'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.QUALITY,
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
label: () => translate('Languages'),
|
||||
type: 'contains',
|
||||
valueType: filterBuilderValueTypes.LANGUAGE,
|
||||
},
|
||||
{
|
||||
name: 'protocol',
|
||||
label: () => translate('Protocol'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.PROTOCOL,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
label: () => translate('Status'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.QUEUE_STATUS,
|
||||
},
|
||||
];
|
||||
|
||||
const useQueue = () => {
|
||||
const { page, goToPage } = usePage('queue');
|
||||
const {
|
||||
includeUnknownSeriesItems,
|
||||
pageSize,
|
||||
selectedFilterKey,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
} = useQueueOptions();
|
||||
const customFilters = useSelector(
|
||||
createCustomFiltersSelector('queue')
|
||||
) as CustomFilter[];
|
||||
|
||||
const filters = useMemo(() => {
|
||||
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
|
||||
}, [selectedFilterKey, customFilters]);
|
||||
|
||||
const { refetch, ...query } = usePagedApiQuery<Queue>({
|
||||
path: '/queue',
|
||||
page,
|
||||
pageSize,
|
||||
filters,
|
||||
queryParams: {
|
||||
includeUnknownSeriesItems,
|
||||
},
|
||||
sortKey,
|
||||
sortDirection,
|
||||
queryOptions: {
|
||||
placeholderData: keepPreviousData,
|
||||
},
|
||||
});
|
||||
|
||||
const handleGoToPage = useCallback(
|
||||
(page: number) => {
|
||||
goToPage(page);
|
||||
},
|
||||
[goToPage]
|
||||
);
|
||||
|
||||
return {
|
||||
...query,
|
||||
goToPage: handleGoToPage,
|
||||
page,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
|
||||
export default useQueue;
|
||||
|
||||
export const useFilters = () => {
|
||||
return FILTERS;
|
||||
};
|
||||
|
||||
const useRemovalOptions = () => {
|
||||
const { removalOptions } = useQueueOptions();
|
||||
|
||||
return {
|
||||
remove: removalOptions.removalMethod === 'removeFromClient',
|
||||
changeCategory: removalOptions.removalMethod === 'changeCategory',
|
||||
blocklist: removalOptions.blocklistMethod !== 'doNotBlocklist',
|
||||
skipRedownload: removalOptions.blocklistMethod === 'blocklistOnly',
|
||||
};
|
||||
};
|
||||
|
||||
export const useRemoveQueueItem = (id: number) => {
|
||||
const queryClient = useQueryClient();
|
||||
const removalOptions = useRemovalOptions();
|
||||
|
||||
const { mutate, isPending } = useApiMutation<unknown, void>({
|
||||
path: `/queue/${id}${getQueryString(removalOptions)}`,
|
||||
method: 'DELETE',
|
||||
mutationOptions: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
removeQueueItem: mutate,
|
||||
isRemoving: isPending,
|
||||
};
|
||||
};
|
||||
|
||||
export const useRemoveQueueItems = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const removalOptions = useRemovalOptions();
|
||||
|
||||
const { mutate, isPending } = useApiMutation<unknown, BulkQueueData>({
|
||||
path: `/queue/bulk${getQueryString(removalOptions)}`,
|
||||
method: 'DELETE',
|
||||
mutationOptions: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
removeQueueItems: mutate,
|
||||
isRemoving: isPending,
|
||||
};
|
||||
};
|
||||
|
||||
export const useGrabQueueItem = (id: number) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [grabError, setGrabError] = useState<string | null>(null);
|
||||
|
||||
const { mutate, isPending } = useApiMutation<unknown, void>({
|
||||
path: `/queue/grab/${id}`,
|
||||
method: 'POST',
|
||||
mutationOptions: {
|
||||
onMutate: () => {
|
||||
setGrabError(null);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
||||
},
|
||||
onError: () => {
|
||||
setGrabError('Error grabbing queue item');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
grabQueueItem: mutate,
|
||||
isGrabbing: isPending,
|
||||
grabError,
|
||||
};
|
||||
};
|
||||
|
||||
export const useGrabQueueItems = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Explicitly define the types for the mutation so we can pass in no arguments to mutate as expected.
|
||||
const { mutate, isPending } = useApiMutation<unknown, BulkQueueData>({
|
||||
path: '/queue/grab/bulk',
|
||||
method: 'POST',
|
||||
mutationOptions: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
grabQueueItems: mutate,
|
||||
isGrabbing: isPending,
|
||||
};
|
||||
};
|
||||
@@ -47,7 +47,11 @@ function AddNewSeriesModalContent({
|
||||
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
||||
const isWindows = useIsWindows();
|
||||
|
||||
const { isAdding, addError, addSeries } = useAddSeries();
|
||||
const {
|
||||
isPending: isAdding,
|
||||
error: addError,
|
||||
mutate: addSeries,
|
||||
} = useAddSeries();
|
||||
|
||||
const { settings, validationErrors, validationWarnings } = useMemo(() => {
|
||||
return selectSettings(options, {}, addError);
|
||||
|
||||
@@ -33,19 +33,11 @@ export const useAddSeries = () => {
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const { isPending, error, mutate } = useApiMutation<Series, AddSeriesPayload>(
|
||||
{
|
||||
path: '/series',
|
||||
method: 'POST',
|
||||
mutationOptions: {
|
||||
onSuccess: onAddSuccess,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
isAdding: isPending,
|
||||
addError: error,
|
||||
addSeries: mutate,
|
||||
};
|
||||
return useApiMutation<Series, AddSeriesPayload>({
|
||||
path: '/series',
|
||||
method: 'POST',
|
||||
mutationOptions: {
|
||||
onSuccess: onAddSuccess,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
|
||||
import { createPersist } from 'Helpers/createPersist';
|
||||
import { SeriesMonitor, SeriesType } from 'Series/Series';
|
||||
|
||||
export interface AddSeriesOptions {
|
||||
@@ -12,8 +12,9 @@ export interface AddSeriesOptions {
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
const { useOptions, useOption, setOption } =
|
||||
createOptionsStore<AddSeriesOptions>('add_series_options', () => {
|
||||
const addSeriesOptionsStore = createPersist<AddSeriesOptions>(
|
||||
'add_series_options',
|
||||
() => {
|
||||
return {
|
||||
rootFolderPath: '',
|
||||
monitor: 'all',
|
||||
@@ -24,8 +25,25 @@ const { useOptions, useOption, setOption } =
|
||||
searchForCutoffUnmetEpisodes: false,
|
||||
tags: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const useAddSeriesOptions = useOptions;
|
||||
export const useAddSeriesOption = useOption;
|
||||
export const setAddSeriesOption = setOption;
|
||||
export const useAddSeriesOptions = () => {
|
||||
return addSeriesOptionsStore((state) => state);
|
||||
};
|
||||
|
||||
export const useAddSeriesOption = <K extends keyof AddSeriesOptions>(
|
||||
key: K
|
||||
) => {
|
||||
return addSeriesOptionsStore((state) => state[key]);
|
||||
};
|
||||
|
||||
export const setAddSeriesOption = <K extends keyof AddSeriesOptions>(
|
||||
key: K,
|
||||
value: AddSeriesOptions[K]
|
||||
) => {
|
||||
addSeriesOptionsStore.setState((state) => ({
|
||||
...state,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import OrganizePreviewAppState from './OrganizePreviewAppState';
|
||||
import ParseAppState from './ParseAppState';
|
||||
import PathsAppState from './PathsAppState';
|
||||
import ProviderOptionsAppState from './ProviderOptionsAppState';
|
||||
import QueueAppState from './QueueAppState';
|
||||
import ReleasesAppState from './ReleasesAppState';
|
||||
import RootFolderAppState from './RootFolderAppState';
|
||||
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
|
||||
@@ -98,6 +99,7 @@ interface AppState {
|
||||
parse: ParseAppState;
|
||||
paths: PathsAppState;
|
||||
providerOptions: ProviderOptionsAppState;
|
||||
queue: QueueAppState;
|
||||
releases: ReleasesAppState;
|
||||
rootFolders: RootFolderAppState;
|
||||
series: SeriesAppState;
|
||||
|
||||
56
frontend/src/App/State/QueueAppState.ts
Normal file
56
frontend/src/App/State/QueueAppState.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import Queue from 'typings/Queue';
|
||||
import AppSectionState, {
|
||||
AppSectionFilterState,
|
||||
AppSectionItemState,
|
||||
Error,
|
||||
PagedAppSectionState,
|
||||
TableAppSectionState,
|
||||
} from './AppSectionState';
|
||||
|
||||
export interface QueueStatus {
|
||||
totalCount: number;
|
||||
count: number;
|
||||
unknownCount: number;
|
||||
errors: boolean;
|
||||
warnings: boolean;
|
||||
unknownErrors: boolean;
|
||||
unknownWarnings: boolean;
|
||||
}
|
||||
|
||||
export interface QueueDetailsAppState extends AppSectionState<Queue> {
|
||||
params: unknown;
|
||||
}
|
||||
|
||||
export interface QueuePagedAppState
|
||||
extends AppSectionState<Queue>,
|
||||
AppSectionFilterState<Queue>,
|
||||
PagedAppSectionState,
|
||||
TableAppSectionState {
|
||||
isGrabbing: boolean;
|
||||
grabError: Error;
|
||||
isRemoving: boolean;
|
||||
removeError: Error;
|
||||
}
|
||||
|
||||
export type RemovalMethod = 'removeFromClient' | 'changeCategory' | 'ignore';
|
||||
export type BlocklistMethod =
|
||||
| 'doNotBlocklist'
|
||||
| 'blocklistAndSearch'
|
||||
| 'blocklistOnly';
|
||||
|
||||
interface RemovalOptions {
|
||||
removalMethod: RemovalMethod;
|
||||
blocklistMethod: BlocklistMethod;
|
||||
}
|
||||
|
||||
interface QueueAppState {
|
||||
status: AppSectionItemState<QueueStatus>;
|
||||
details: QueueDetailsAppState;
|
||||
paged: QueuePagedAppState;
|
||||
options: {
|
||||
includeUnknownSeriesItems: boolean;
|
||||
};
|
||||
removalOptions: RemovalOptions;
|
||||
}
|
||||
|
||||
export default QueueAppState;
|
||||
@@ -2,7 +2,6 @@ import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import AppState from 'App/State/AppState';
|
||||
import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
|
||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||
@@ -14,6 +13,7 @@ import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
||||
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import formatTime from 'Utilities/Date/formatTime';
|
||||
import padNumber from 'Utilities/Number/padNumber';
|
||||
@@ -57,7 +57,7 @@ function AgendaEvent(props: AgendaEventProps) {
|
||||
|
||||
const series = useSeries(seriesId)!;
|
||||
const episodeFile = useEpisodeFile(episodeFileId);
|
||||
const queueItem = useQueueItemForEpisode(id);
|
||||
const queueItem = useSelector(createQueueItemSelectorForHook(id));
|
||||
const { timeFormat, longDateFormat, enableColorImpairedMode } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
clearEpisodeFiles,
|
||||
fetchEpisodeFiles,
|
||||
} from 'Store/Actions/episodeFileActions';
|
||||
import {
|
||||
clearQueueDetails,
|
||||
fetchQueueDetails,
|
||||
} from 'Store/Actions/queueActions';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
|
||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||
@@ -70,6 +74,7 @@ function Calendar() {
|
||||
|
||||
return () => {
|
||||
dispatch(clearCalendar());
|
||||
dispatch(clearQueueDetails());
|
||||
dispatch(clearEpisodeFiles());
|
||||
clearTimeout(updateTimeout.current);
|
||||
};
|
||||
@@ -85,6 +90,7 @@ function Calendar() {
|
||||
|
||||
useEffect(() => {
|
||||
const repopulate = () => {
|
||||
dispatch(fetchQueueDetails({ time, view }));
|
||||
dispatch(fetchCalendar({ time, view }));
|
||||
};
|
||||
|
||||
@@ -119,11 +125,16 @@ function Calendar() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!previousItems || hasDifferentItems(items, previousItems)) {
|
||||
const episodeIds = selectUniqueIds<Episode, number>(items, 'id');
|
||||
const episodeFileIds = selectUniqueIds<Episode, number>(
|
||||
items,
|
||||
'episodeFileId'
|
||||
);
|
||||
|
||||
if (items.length) {
|
||||
dispatch(fetchQueueDetails({ episodeIds }));
|
||||
}
|
||||
|
||||
if (episodeFileIds.length) {
|
||||
dispatch(fetchEpisodeFiles({ episodeFileIds }));
|
||||
}
|
||||
@@ -133,15 +144,18 @@ function Calendar() {
|
||||
return (
|
||||
<div className={styles.calendar}>
|
||||
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
|
||||
|
||||
{!isFetching && error ? (
|
||||
<Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert>
|
||||
) : null}
|
||||
|
||||
{!error && isPopulated && view === 'agenda' ? (
|
||||
<div className={styles.calendarContent}>
|
||||
<CalendarHeader />
|
||||
<Agenda />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!error && isPopulated && view !== 'agenda' ? (
|
||||
<div className={styles.calendarContent}>
|
||||
<CalendarHeader />
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import moment from 'moment';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { useQueueDetails } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import AppState from 'App/State/AppState';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||
import Queue from 'typings/Queue';
|
||||
import { isCommandExecuting } from 'Utilities/Command';
|
||||
import isBefore from 'Utilities/Date/isBefore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
function createIsSearchingSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.calendar.searchMissingCommandId,
|
||||
createCommandsSelector(),
|
||||
(searchMissingCommandId, commands) => {
|
||||
if (searchMissingCommandId == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isCommandExecuting(
|
||||
commands.find((command) => {
|
||||
return command.id === searchMissingCommandId;
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMissingEpisodeIdsSelector(queueDetails: Queue[]) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.calendar.start,
|
||||
(state: AppState) => state.calendar.end,
|
||||
(state: AppState) => state.calendar.items,
|
||||
(start, end, episodes) => {
|
||||
return episodes.reduce<number[]>((acc, episode) => {
|
||||
const airDateUtc = episode.airDateUtc;
|
||||
|
||||
if (
|
||||
!episode.episodeFileId &&
|
||||
moment(airDateUtc).isAfter(start) &&
|
||||
moment(airDateUtc).isBefore(end) &&
|
||||
isBefore(episode.airDateUtc) &&
|
||||
!queueDetails.some(
|
||||
(details) => !!details.episode && details.episode.id === episode.id
|
||||
)
|
||||
) {
|
||||
acc.push(episode.id);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default function CalendarMissingEpisodeSearchButton() {
|
||||
const queueDetails = useQueueDetails();
|
||||
const missingEpisodeIds = useSelector(
|
||||
createMissingEpisodeIdsSelector(queueDetails)
|
||||
);
|
||||
const isSearchingForMissing = useSelector(createIsSearchingSelector());
|
||||
|
||||
const handlePress = useCallback(() => {}, []);
|
||||
|
||||
return (
|
||||
<PageToolbarButton
|
||||
label={translate('SearchForMissing')}
|
||||
iconName={icons.SEARCH}
|
||||
isDisabled={!missingEpisodeIds.length}
|
||||
isSpinning={isSearchingForMissing}
|
||||
onPress={handlePress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import QueueDetails from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||
@@ -10,23 +11,24 @@ import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
||||
import Episode from 'Episode/Episode';
|
||||
import useMeasure from 'Helpers/Hooks/useMeasure';
|
||||
import { align, icons } from 'Helpers/Props';
|
||||
import NoSeries from 'Series/NoSeries';
|
||||
import {
|
||||
searchMissing,
|
||||
setCalendarDaysCount,
|
||||
setCalendarFilter,
|
||||
} from 'Store/Actions/calendarActions';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||
import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
|
||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||
import { isCommandExecuting } from 'Utilities/Command';
|
||||
import isBefore from 'Utilities/Date/isBefore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import Calendar from './Calendar';
|
||||
import CalendarFilterModal from './CalendarFilterModal';
|
||||
import CalendarMissingEpisodeSearchButton from './CalendarMissingEpisodeSearchButton';
|
||||
import CalendarLinkModal from './iCal/CalendarLinkModal';
|
||||
import Legend from './Legend/Legend';
|
||||
import CalendarOptionsModal from './Options/CalendarOptionsModal';
|
||||
@@ -34,12 +36,60 @@ import styles from './CalendarPage.css';
|
||||
|
||||
const MINIMUM_DAY_WIDTH = 120;
|
||||
|
||||
function createMissingEpisodeIdsSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.calendar.start,
|
||||
(state: AppState) => state.calendar.end,
|
||||
(state: AppState) => state.calendar.items,
|
||||
(state: AppState) => state.queue.details.items,
|
||||
(start, end, episodes, queueDetails) => {
|
||||
return episodes.reduce<number[]>((acc, episode) => {
|
||||
const airDateUtc = episode.airDateUtc;
|
||||
|
||||
if (
|
||||
!episode.episodeFileId &&
|
||||
moment(airDateUtc).isAfter(start) &&
|
||||
moment(airDateUtc).isBefore(end) &&
|
||||
isBefore(episode.airDateUtc) &&
|
||||
!queueDetails.some(
|
||||
(details) => !!details.episode && details.episode.id === episode.id
|
||||
)
|
||||
) {
|
||||
acc.push(episode.id);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createIsSearchingSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.calendar.searchMissingCommandId,
|
||||
createCommandsSelector(),
|
||||
(searchMissingCommandId, commands) => {
|
||||
if (searchMissingCommandId == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isCommandExecuting(
|
||||
commands.find((command) => {
|
||||
return command.id === searchMissingCommandId;
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarPage() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { selectedFilterKey, filters, items } = useSelector(
|
||||
const { selectedFilterKey, filters } = useSelector(
|
||||
(state: AppState) => state.calendar
|
||||
);
|
||||
const missingEpisodeIds = useSelector(createMissingEpisodeIdsSelector());
|
||||
const isSearchingForMissing = useSelector(createIsSearchingSelector());
|
||||
const isRssSyncExecuting = useSelector(
|
||||
createCommandExecutingSelector(commandNames.RSS_SYNC)
|
||||
);
|
||||
@@ -77,6 +127,10 @@ function CalendarPage() {
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
const handleSearchMissingPress = useCallback(() => {
|
||||
dispatch(searchMissing({ episodeIds: missingEpisodeIds }));
|
||||
}, [missingEpisodeIds, dispatch]);
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(key: string | number) => {
|
||||
dispatch(setCalendarFilter({ selectedFilterKey: key }));
|
||||
@@ -84,10 +138,6 @@ function CalendarPage() {
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const episodeIds = useMemo(() => {
|
||||
return selectUniqueIds<Episode, number>(items, 'id');
|
||||
}, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (width === 0) {
|
||||
return;
|
||||
@@ -102,67 +152,71 @@ function CalendarPage() {
|
||||
}, [width, dispatch]);
|
||||
|
||||
return (
|
||||
<QueueDetails episodeIds={episodeIds}>
|
||||
<PageContent title={translate('Calendar')}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('ICalLink')}
|
||||
iconName={icons.CALENDAR}
|
||||
onPress={handleGetCalendarLinkPress}
|
||||
/>
|
||||
<PageContent title={translate('Calendar')}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('ICalLink')}
|
||||
iconName={icons.CALENDAR}
|
||||
onPress={handleGetCalendarLinkPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('RssSync')}
|
||||
iconName={icons.RSS}
|
||||
isSpinning={isRssSyncExecuting}
|
||||
onPress={handleRssSyncPress}
|
||||
/>
|
||||
<PageToolbarButton
|
||||
label={translate('RssSync')}
|
||||
iconName={icons.RSS}
|
||||
isSpinning={isRssSyncExecuting}
|
||||
onPress={handleRssSyncPress}
|
||||
/>
|
||||
|
||||
<CalendarMissingEpisodeSearchButton />
|
||||
</PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('SearchForMissing')}
|
||||
iconName={icons.SEARCH}
|
||||
isDisabled={!missingEpisodeIds.length}
|
||||
isSpinning={isSearchingForMissing}
|
||||
onPress={handleSearchMissingPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={icons.POSTER}
|
||||
onPress={handleOptionsPress}
|
||||
/>
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={icons.POSTER}
|
||||
onPress={handleOptionsPress}
|
||||
/>
|
||||
|
||||
<FilterMenu
|
||||
alignMenu={align.RIGHT}
|
||||
isDisabled={!hasSeries}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={customFilters}
|
||||
filterModalConnectorComponent={CalendarFilterModal}
|
||||
onFilterSelect={handleFilterSelect}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
<FilterMenu
|
||||
alignMenu={align.RIGHT}
|
||||
isDisabled={!hasSeries}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={customFilters}
|
||||
filterModalConnectorComponent={CalendarFilterModal}
|
||||
onFilterSelect={handleFilterSelect}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
|
||||
<PageContentBody
|
||||
ref={pageContentRef}
|
||||
className={styles.calendarPageBody}
|
||||
innerClassName={styles.calendarInnerPageBody}
|
||||
>
|
||||
{isMeasured ? <PageComponent totalItems={0} /> : <div />}
|
||||
{hasSeries && <Legend />}
|
||||
</PageContentBody>
|
||||
<PageContentBody
|
||||
ref={pageContentRef}
|
||||
className={styles.calendarPageBody}
|
||||
innerClassName={styles.calendarInnerPageBody}
|
||||
>
|
||||
{isMeasured ? <PageComponent totalItems={0} /> : <div />}
|
||||
{hasSeries && <Legend />}
|
||||
</PageContentBody>
|
||||
|
||||
<CalendarLinkModal
|
||||
isOpen={isCalendarLinkModalOpen}
|
||||
onModalClose={handleGetCalendarLinkModalClose}
|
||||
/>
|
||||
<CalendarLinkModal
|
||||
isOpen={isCalendarLinkModalOpen}
|
||||
onModalClose={handleGetCalendarLinkModalClose}
|
||||
/>
|
||||
|
||||
<CalendarOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={handleOptionsModalClose}
|
||||
/>
|
||||
</PageContent>
|
||||
</QueueDetails>
|
||||
<CalendarOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={handleOptionsModalClose}
|
||||
/>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import AppState from 'App/State/AppState';
|
||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||
import Icon from 'Components/Icon';
|
||||
@@ -13,6 +12,7 @@ import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
||||
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import formatTime from 'Utilities/Date/formatTime';
|
||||
import padNumber from 'Utilities/Number/padNumber';
|
||||
@@ -58,7 +58,7 @@ function CalendarEvent(props: CalendarEventProps) {
|
||||
|
||||
const series = useSeries(seriesId);
|
||||
const episodeFile = useEpisodeFile(episodeFileId);
|
||||
const queueItem = useQueueItemForEpisode(id);
|
||||
const queueItem = useSelector(createQueueItemSelectorForHook(id));
|
||||
|
||||
const { timeFormat, enableColorImpairedMode } = useSelector(
|
||||
createUISettingsSelector()
|
||||
|
||||
@@ -2,7 +2,7 @@ import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useIsDownloadingEpisodes } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||
import Icon from 'Components/Icon';
|
||||
@@ -18,6 +18,17 @@ import translate from 'Utilities/String/translate';
|
||||
import CalendarEvent from './CalendarEvent';
|
||||
import styles from './CalendarEventGroup.css';
|
||||
|
||||
function createIsDownloadingSelector(episodeIds: number[]) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.queue.details,
|
||||
(details) => {
|
||||
return details.items.some(
|
||||
(item) => item.episodeId && episodeIds.includes(item.episodeId)
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface CalendarEventGroupProps {
|
||||
episodeIds: number[];
|
||||
seriesId: number;
|
||||
@@ -31,7 +42,7 @@ function CalendarEventGroup({
|
||||
events,
|
||||
onEventModalOpenToggle,
|
||||
}: CalendarEventGroupProps) {
|
||||
const isDownloading = useIsDownloadingEpisodes(episodeIds);
|
||||
const isDownloading = useSelector(createIsDownloadingSelector(episodeIds));
|
||||
const series = useSeries(seriesId)!;
|
||||
|
||||
const { timeFormat, enableColorImpairedMode } = useSelector(
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
interface CalendarEventQueueDetailsProps {
|
||||
title: string;
|
||||
size: number;
|
||||
sizeLeft: number;
|
||||
sizeleft: number;
|
||||
estimatedCompletionTime?: string;
|
||||
status: string;
|
||||
trackedDownloadState: QueueTrackedDownloadState;
|
||||
@@ -22,7 +22,7 @@ interface CalendarEventQueueDetailsProps {
|
||||
function CalendarEventQueueDetails({
|
||||
title,
|
||||
size,
|
||||
sizeLeft,
|
||||
sizeleft,
|
||||
estimatedCompletionTime,
|
||||
status,
|
||||
trackedDownloadState,
|
||||
@@ -30,13 +30,13 @@ function CalendarEventQueueDetails({
|
||||
statusMessages,
|
||||
errorMessage,
|
||||
}: CalendarEventQueueDetailsProps) {
|
||||
const progress = size ? 100 - (sizeLeft / size) * 100 : 0;
|
||||
const progress = size ? 100 - (sizeleft / size) * 100 : 0;
|
||||
|
||||
return (
|
||||
<QueueDetails
|
||||
title={title}
|
||||
size={size}
|
||||
sizeLeft={sizeLeft}
|
||||
sizeleft={sizeleft}
|
||||
estimatedCompletionTime={estimatedCompletionTime}
|
||||
status={status}
|
||||
trackedDownloadState={trackedDownloadState}
|
||||
|
||||
@@ -3,18 +3,19 @@ import {
|
||||
HubConnectionBuilder,
|
||||
LogLevel,
|
||||
} from '@microsoft/signalr';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Command from 'Commands/Command';
|
||||
import { setAppValue, setVersion } from 'Store/Actions/appActions';
|
||||
import { removeItem, updateItem } from 'Store/Actions/baseActions';
|
||||
import { removeItem, update, updateItem } from 'Store/Actions/baseActions';
|
||||
import {
|
||||
fetchCommands,
|
||||
finishCommand,
|
||||
updateCommand,
|
||||
} from 'Store/Actions/commandActions';
|
||||
import { fetchQueue, fetchQueueDetails } from 'Store/Actions/queueActions';
|
||||
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||
import { fetchSeries } from 'Store/Actions/seriesActions';
|
||||
import { fetchQualityDefinitions } from 'Store/Actions/settingsActions';
|
||||
@@ -32,13 +33,15 @@ interface SignalRMessage {
|
||||
resource: ModelBase;
|
||||
version: string;
|
||||
};
|
||||
version: number | undefined;
|
||||
}
|
||||
|
||||
function SignalRListener() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const isQueuePopulated = useSelector(
|
||||
(state: AppState) => state.queue.paged.isPopulated
|
||||
);
|
||||
|
||||
const connection = useRef<HubConnection | null>(null);
|
||||
|
||||
const handleStartFail = useRef((error: unknown) => {
|
||||
@@ -94,14 +97,9 @@ function SignalRListener() {
|
||||
});
|
||||
|
||||
const handleReceiveMessage = useRef((message: SignalRMessage) => {
|
||||
console.debug(
|
||||
`[signalR] received ${message.name}${
|
||||
message.version ? ` v${message.version}` : ''
|
||||
}`,
|
||||
message.body
|
||||
);
|
||||
console.debug('[signalR] received', message.name, message.body);
|
||||
|
||||
const { name, body, version = 0 } = message;
|
||||
const { name, body } = message;
|
||||
|
||||
if (name === 'calendar') {
|
||||
if (body.action === 'updated') {
|
||||
@@ -237,36 +235,20 @@ function SignalRListener() {
|
||||
}
|
||||
|
||||
if (name === 'queue') {
|
||||
if (version < 5) {
|
||||
return;
|
||||
if (isQueuePopulated) {
|
||||
dispatch(fetchQueue());
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'queue/details') {
|
||||
if (version < 5) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['/queue/details'] });
|
||||
dispatch(fetchQueueDetails());
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'queue/status') {
|
||||
if (version < 5) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusDetails = queryClient.getQueriesData({
|
||||
queryKey: ['/queue/status'],
|
||||
});
|
||||
|
||||
statusDetails.forEach(([queryKey]) => {
|
||||
queryClient.setQueryData(queryKey, () => body.resource);
|
||||
});
|
||||
|
||||
dispatch(update({ section: 'queue.status', data: body.resource }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ function RelativeDateCell(props: RelativeDateCellProps) {
|
||||
date,
|
||||
includeSeconds = false,
|
||||
includeTime = false,
|
||||
|
||||
component: Component = TableRowCell,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import { useSelector } from 'react-redux';
|
||||
import QueueDetails from 'Activity/Queue/QueueDetails';
|
||||
import Icon from 'Components/Icon';
|
||||
import ProgressBar from 'Components/ProgressBar';
|
||||
@@ -7,6 +7,7 @@ import Episode from 'Episode/Episode';
|
||||
import useEpisode, { EpisodeEntity } from 'Episode/useEpisode';
|
||||
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
|
||||
import isBefore from 'Utilities/Date/isBefore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EpisodeQuality from './EpisodeQuality';
|
||||
@@ -29,7 +30,7 @@ function EpisodeStatus({
|
||||
grabbed = false,
|
||||
} = useEpisode(episodeId, episodeEntity) as Episode;
|
||||
|
||||
const queueItem = useQueueItemForEpisode(episodeId);
|
||||
const queueItem = useSelector(createQueueItemSelectorForHook(episodeId));
|
||||
const episodeFile = useEpisodeFile(episodeFileId);
|
||||
|
||||
const hasEpisodeFile = !!episodeFile;
|
||||
@@ -37,9 +38,9 @@ function EpisodeStatus({
|
||||
const hasAired = isBefore(airDateUtc);
|
||||
|
||||
if (isQueued) {
|
||||
const { sizeLeft, size } = queueItem;
|
||||
const { sizeleft, size } = queueItem;
|
||||
|
||||
const progress = size ? 100 - (sizeLeft / size) * 100 : 0;
|
||||
const progress = size ? 100 - (sizeleft / size) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className={styles.center}>
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Episode from './Episode';
|
||||
|
||||
export type EpisodeEntity =
|
||||
| 'calendar'
|
||||
| 'episodes'
|
||||
| 'interactiveImport.episodes'
|
||||
| 'wanted.cutoffUnmet'
|
||||
| 'wanted.missing';
|
||||
|
||||
function getEpisodes(episodeIds: number[], episodes: Episode[]) {
|
||||
return episodeIds.reduce<Episode[]>((acc, id) => {
|
||||
const episode = episodes.find((episode) => episode.id === id);
|
||||
|
||||
if (episode) {
|
||||
acc.push(episode);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function createEpisodeSelector(episodeIds: number[]) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.episodes.items,
|
||||
(episodes) => {
|
||||
return getEpisodes(episodeIds, episodes);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createCalendarEpisodeSelector(episodeIds: number[]) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.calendar.items as Episode[],
|
||||
(episodes) => {
|
||||
return getEpisodes(episodeIds, episodes);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createWantedCutoffUnmetEpisodeSelector(episodeIds: number[]) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.wanted.cutoffUnmet.items,
|
||||
(episodes) => {
|
||||
return getEpisodes(episodeIds, episodes);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createWantedMissingEpisodeSelector(episodeIds: number[]) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.wanted.missing.items,
|
||||
(episodes) => {
|
||||
return getEpisodes(episodeIds, episodes);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default function useEpisodes(
|
||||
episodeIds: number[],
|
||||
episodeEntity: EpisodeEntity
|
||||
) {
|
||||
let selector = createEpisodeSelector;
|
||||
|
||||
switch (episodeEntity) {
|
||||
case 'calendar':
|
||||
selector = createCalendarEpisodeSelector;
|
||||
break;
|
||||
case 'wanted.cutoffUnmet':
|
||||
selector = createWantedCutoffUnmetEpisodeSelector;
|
||||
break;
|
||||
case 'wanted.missing':
|
||||
selector = createWantedMissingEpisodeSelector;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return useSelector(selector(episodeIds));
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { Error } from 'App/State/AppSectionState';
|
||||
import fetchJson, { FetchJsonOptions } from 'Utilities/Fetch/fetchJson';
|
||||
import getQueryPath from 'Utilities/Fetch/getQueryPath';
|
||||
import getQueryString, { QueryParams } from 'Utilities/Fetch/getQueryString';
|
||||
import fetchJson, {
|
||||
apiRoot,
|
||||
FetchJsonOptions,
|
||||
} from 'Utilities/Fetch/fetchJson';
|
||||
|
||||
interface MutationOptions<T, TData>
|
||||
extends Omit<FetchJsonOptions<TData>, 'method'> {
|
||||
method: 'POST' | 'PUT' | 'DELETE';
|
||||
mutationOptions?: Omit<UseMutationOptions<T, Error, TData>, 'mutationFn'>;
|
||||
queryParams?: QueryParams;
|
||||
}
|
||||
|
||||
function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
|
||||
const requestOptions = useMemo(() => {
|
||||
return {
|
||||
...options,
|
||||
path: getQueryPath(options.path) + getQueryString(options.queryParams),
|
||||
path: apiRoot + options.path,
|
||||
headers: {
|
||||
...options.headers,
|
||||
'X-Api-Key': window.Sonarr.apiKey,
|
||||
@@ -26,11 +26,8 @@ function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
|
||||
|
||||
return useMutation<T, Error, TData>({
|
||||
...options.mutationOptions,
|
||||
mutationFn: async (data?: TData) => {
|
||||
const { path, ...otherOptions } = requestOptions;
|
||||
|
||||
return fetchJson<T, TData>({ path, ...otherOptions, body: data });
|
||||
},
|
||||
mutationFn: async (data: TData) =>
|
||||
fetchJson<T, TData>({ ...requestOptions, body: data }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,25 +15,22 @@ export interface QueryOptions<T> extends FetchJsonOptions<unknown> {
|
||||
}
|
||||
|
||||
const useApiQuery = <T>(options: QueryOptions<T>) => {
|
||||
const { queryKey, requestOptions } = useMemo(() => {
|
||||
const requestOptions = useMemo(() => {
|
||||
const { path: path, queryOptions, queryParams, ...otherOptions } = options;
|
||||
|
||||
return {
|
||||
queryKey: [path, queryParams],
|
||||
requestOptions: {
|
||||
...otherOptions,
|
||||
path: getQueryPath(path) + getQueryString(queryParams),
|
||||
headers: {
|
||||
...options.headers,
|
||||
'X-Api-Key': window.Sonarr.apiKey,
|
||||
},
|
||||
...otherOptions,
|
||||
path: getQueryPath(path) + getQueryString(queryParams),
|
||||
headers: {
|
||||
...options.headers,
|
||||
'X-Api-Key': window.Sonarr.apiKey,
|
||||
},
|
||||
};
|
||||
}, [options]);
|
||||
|
||||
return useQuery({
|
||||
...options.queryOptions,
|
||||
queryKey,
|
||||
queryKey: [requestOptions.path],
|
||||
queryFn: async ({ signal }) =>
|
||||
fetchJson<T, unknown>({ ...requestOptions, signal }),
|
||||
});
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { StateCreator } from 'zustand';
|
||||
import { PersistOptions } from 'zustand/middleware';
|
||||
import Column from 'Components/Table/Column';
|
||||
import { createPersist } from 'Helpers/createPersist';
|
||||
|
||||
type TSettingsWithoutColumns = object;
|
||||
|
||||
interface TSettingsWithColumns {
|
||||
columns: Column[];
|
||||
}
|
||||
|
||||
type TSettingd = TSettingsWithoutColumns | TSettingsWithColumns;
|
||||
|
||||
export type OptionChanged<T> = {
|
||||
name: keyof T;
|
||||
value: T[keyof T];
|
||||
};
|
||||
|
||||
export const createOptionsStore = <T extends TSettingd>(
|
||||
name: string,
|
||||
state: StateCreator<T>,
|
||||
options: Omit<PersistOptions<T>, 'name' | 'storage'> = {}
|
||||
) => {
|
||||
const store = createPersist<T>(name, state, {
|
||||
merge,
|
||||
...options,
|
||||
});
|
||||
|
||||
const useOptions = () => {
|
||||
return store((state) => state);
|
||||
};
|
||||
|
||||
const useOption = <K extends keyof T>(key: K) => {
|
||||
return store((state) => state[key]);
|
||||
};
|
||||
|
||||
const setOptions = (options: Partial<T>) => {
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
...options,
|
||||
}));
|
||||
};
|
||||
|
||||
const setOption = <K extends keyof T>(key: K, value: T[K]) => {
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
store,
|
||||
useOptions,
|
||||
useOption,
|
||||
setOptions,
|
||||
setOption,
|
||||
};
|
||||
};
|
||||
|
||||
const merge = <T extends TSettingd>(
|
||||
persistedState: unknown,
|
||||
currentState: T
|
||||
) => {
|
||||
if ('columns' in currentState) {
|
||||
return {
|
||||
...currentState,
|
||||
...mergeColumns(persistedState, currentState),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
...((persistedState as T) ?? {}),
|
||||
};
|
||||
};
|
||||
|
||||
const mergeColumns = <T extends { columns: Column[] }>(
|
||||
persistedState: unknown,
|
||||
currentState: T
|
||||
) => {
|
||||
const currentColumns = currentState.columns;
|
||||
const persistedColumns = (persistedState as T).columns;
|
||||
const columns: Column[] = [];
|
||||
|
||||
// Add persisted columns in the same order they're currently in
|
||||
// as long as they haven't been removed.
|
||||
|
||||
persistedColumns.forEach((persistedColumn) => {
|
||||
const column = currentColumns.find((i) => i.name === persistedColumn.name);
|
||||
|
||||
if (column) {
|
||||
const newColumn: Partial<Column> = {};
|
||||
|
||||
// We can't use a spread operator or Object.assign to clone the column
|
||||
// or any accessors are lost and can break translations.
|
||||
for (const prop of Object.keys(column)) {
|
||||
const attributes = Object.getOwnPropertyDescriptor(column, prop);
|
||||
|
||||
if (!attributes) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.defineProperty(newColumn, prop, attributes);
|
||||
}
|
||||
|
||||
newColumn.isVisible = persistedColumn.isVisible;
|
||||
|
||||
columns.push(newColumn as Column);
|
||||
}
|
||||
});
|
||||
|
||||
// Add any columns added to the app in the initial position.
|
||||
currentColumns.forEach((currentColumn, index) => {
|
||||
const persistedColumnIndex = persistedColumns.findIndex(
|
||||
(i) => i.name === currentColumn.name
|
||||
);
|
||||
const column = Object.assign({}, currentColumn);
|
||||
|
||||
if (persistedColumnIndex === -1) {
|
||||
columns.splice(index, 0, column);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...(persistedState as T),
|
||||
columns,
|
||||
};
|
||||
};
|
||||
@@ -4,12 +4,10 @@ import { create } from 'zustand';
|
||||
|
||||
interface PageStore {
|
||||
events: number;
|
||||
queue: number;
|
||||
}
|
||||
|
||||
const pageStore = create<PageStore>(() => ({
|
||||
events: 1,
|
||||
queue: 1,
|
||||
}));
|
||||
|
||||
const usePage = (kind: keyof PageStore) => {
|
||||
|
||||
@@ -26,7 +26,7 @@ interface PagedQueryResponse<T> {
|
||||
}
|
||||
|
||||
const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
|
||||
const { requestOptions, queryKey } = useMemo(() => {
|
||||
const requestOptions = useMemo(() => {
|
||||
const {
|
||||
path,
|
||||
page,
|
||||
@@ -40,38 +40,27 @@ const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
|
||||
} = options;
|
||||
|
||||
return {
|
||||
queryKey: [
|
||||
path,
|
||||
queryParams,
|
||||
page,
|
||||
pageSize,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
filters,
|
||||
],
|
||||
requestOptions: {
|
||||
...otherOptions,
|
||||
path:
|
||||
getQueryPath(path) +
|
||||
getQueryString({
|
||||
...queryParams,
|
||||
page,
|
||||
pageSize,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
filters,
|
||||
}),
|
||||
headers: {
|
||||
...options.headers,
|
||||
'X-Api-Key': window.Sonarr.apiKey,
|
||||
},
|
||||
...otherOptions,
|
||||
path:
|
||||
getQueryPath(path) +
|
||||
getQueryString({
|
||||
...queryParams,
|
||||
page,
|
||||
pageSize,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
filters,
|
||||
}),
|
||||
headers: {
|
||||
...options.headers,
|
||||
'X-Api-Key': window.Sonarr.apiKey,
|
||||
},
|
||||
};
|
||||
}, [options]);
|
||||
|
||||
return useQuery({
|
||||
...options.queryOptions,
|
||||
queryKey,
|
||||
queryKey: [requestOptions.path],
|
||||
queryFn: async ({ signal }) => {
|
||||
const response = await fetchJson<PagedQueryResponse<T>, unknown>({
|
||||
...requestOptions,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create, type StateCreator } from 'zustand';
|
||||
import { persist, type PersistOptions } from 'zustand/middleware';
|
||||
import Column from 'Components/Table/Column';
|
||||
|
||||
export const createPersist = <T>(
|
||||
name: string,
|
||||
@@ -18,3 +19,56 @@ export const createPersist = <T>(
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const mergeColumns = <T extends { columns: Column[] }>(
|
||||
persistedState: unknown,
|
||||
currentState: T
|
||||
) => {
|
||||
const currentColumns = currentState.columns;
|
||||
const persistedColumns = (persistedState as T).columns;
|
||||
const columns: Column[] = [];
|
||||
|
||||
// Add persisted columns in the same order they're currently in
|
||||
// as long as they haven't been removed.
|
||||
|
||||
persistedColumns.forEach((persistedColumn) => {
|
||||
const column = currentColumns.find((i) => i.name === persistedColumn.name);
|
||||
|
||||
if (column) {
|
||||
const newColumn: Partial<Column> = {};
|
||||
|
||||
// We can't use a spread operator or Object.assign to clone the column
|
||||
// or any accessors are lost and can break translations.
|
||||
for (const prop of Object.keys(column)) {
|
||||
const attributes = Object.getOwnPropertyDescriptor(column, prop);
|
||||
|
||||
if (!attributes) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.defineProperty(newColumn, prop, attributes);
|
||||
}
|
||||
|
||||
newColumn.isVisible = persistedColumn.isVisible;
|
||||
|
||||
columns.push(newColumn as Column);
|
||||
}
|
||||
});
|
||||
|
||||
// Add any columns added to the app in the initial position.
|
||||
currentColumns.forEach((currentColumn, index) => {
|
||||
const persistedColumnIndex = persistedColumns.findIndex(
|
||||
(i) => i.name === currentColumn.name
|
||||
);
|
||||
const column = Object.assign({}, currentColumn);
|
||||
|
||||
if (persistedColumnIndex === -1) {
|
||||
columns.splice(index, 0, column);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...(persistedState as T),
|
||||
columns,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React from 'react';
|
||||
import { useQueueDetailsForSeries } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import createSeriesQueueItemsDetailsSelector, {
|
||||
SeriesQueueDetails,
|
||||
} from 'Series/Index/createSeriesQueueDetailsSelector';
|
||||
|
||||
function getEpisodeCountKind(
|
||||
monitored: boolean,
|
||||
@@ -41,7 +44,9 @@ function SeasonProgressLabel({
|
||||
episodeCount,
|
||||
episodeFileCount,
|
||||
}: SeasonProgressLabelProps) {
|
||||
const queueDetails = useQueueDetailsForSeries(seriesId, seasonNumber);
|
||||
const queueDetails: SeriesQueueDetails = useSelector(
|
||||
createSeriesQueueItemsDetailsSelector(seriesId, seasonNumber)
|
||||
);
|
||||
|
||||
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
|
||||
const text = newDownloads
|
||||
|
||||
@@ -2,7 +2,6 @@ import moment from 'moment';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import Alert from 'Components/Alert';
|
||||
@@ -48,6 +47,10 @@ import {
|
||||
clearEpisodeFiles,
|
||||
fetchEpisodeFiles,
|
||||
} from 'Store/Actions/episodeFileActions';
|
||||
import {
|
||||
clearQueueDetails,
|
||||
fetchQueueDetails,
|
||||
} from 'Store/Actions/queueActions';
|
||||
import { toggleSeriesMonitored } from 'Store/Actions/seriesActions';
|
||||
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||
@@ -377,6 +380,7 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
|
||||
const populate = useCallback(() => {
|
||||
dispatch(fetchEpisodes({ seriesId }));
|
||||
dispatch(fetchEpisodeFiles({ seriesId }));
|
||||
dispatch(fetchQueueDetails({ seriesId }));
|
||||
}, [seriesId, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -390,6 +394,7 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
|
||||
unregisterPagePopulator(populate);
|
||||
dispatch(clearEpisodes());
|
||||
dispatch(clearEpisodeFiles());
|
||||
dispatch(clearQueueDetails());
|
||||
};
|
||||
}, [populate, dispatch]);
|
||||
|
||||
@@ -461,435 +466,424 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
|
||||
const isPopulated = isEpisodesPopulated && isEpisodeFilesPopulated;
|
||||
|
||||
return (
|
||||
<QueueDetailsProvider seriesId={seriesId}>
|
||||
<PageContent title={title}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('RefreshAndScan')}
|
||||
iconName={icons.REFRESH}
|
||||
spinningName={icons.REFRESH}
|
||||
title={translate('RefreshAndScanTooltip')}
|
||||
isSpinning={isRefreshing}
|
||||
onPress={handleRefreshPress}
|
||||
<PageContent title={title}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('RefreshAndScan')}
|
||||
iconName={icons.REFRESH}
|
||||
spinningName={icons.REFRESH}
|
||||
title={translate('RefreshAndScanTooltip')}
|
||||
isSpinning={isRefreshing}
|
||||
onPress={handleRefreshPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('SearchMonitored')}
|
||||
iconName={icons.SEARCH}
|
||||
isDisabled={!monitored || !hasMonitoredEpisodes || !hasEpisodes}
|
||||
isSpinning={isSearching}
|
||||
title={
|
||||
hasMonitoredEpisodes
|
||||
? undefined
|
||||
: translate('NoMonitoredEpisodes')
|
||||
}
|
||||
onPress={handleSearchPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('PreviewRename')}
|
||||
iconName={icons.ORGANIZE}
|
||||
isDisabled={!hasEpisodeFiles}
|
||||
onPress={handleOrganizePress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('ManageEpisodes')}
|
||||
iconName={icons.EPISODE_FILE}
|
||||
onPress={handleManageEpisodesPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('History')}
|
||||
iconName={icons.HISTORY}
|
||||
isDisabled={!hasEpisodes}
|
||||
onPress={handleSeriesHistoryPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('EpisodeMonitoring')}
|
||||
iconName={icons.MONITORED}
|
||||
onPress={handleMonitorOptionsPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('Edit')}
|
||||
iconName={icons.EDIT}
|
||||
onPress={handleEditSeriesPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('Delete')}
|
||||
iconName={icons.DELETE}
|
||||
onPress={handleDeleteSeriesPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<PageToolbarButton
|
||||
label={
|
||||
expandedState.allExpanded
|
||||
? translate('CollapseAll')
|
||||
: translate('ExpandAll')
|
||||
}
|
||||
iconName={expandIcon}
|
||||
onPress={handleExpandAllPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
|
||||
<PageContentBody innerClassName={styles.innerContentBody}>
|
||||
<div className={styles.header}>
|
||||
<div
|
||||
className={styles.backdrop}
|
||||
style={
|
||||
fanartUrl ? { backgroundImage: `url(${fanartUrl})` } : undefined
|
||||
}
|
||||
>
|
||||
<div className={styles.backdropOverlay} />
|
||||
</div>
|
||||
|
||||
<div className={styles.headerContent}>
|
||||
<SeriesPoster
|
||||
className={styles.poster}
|
||||
images={images}
|
||||
size={500}
|
||||
lazy={false}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('SearchMonitored')}
|
||||
iconName={icons.SEARCH}
|
||||
isDisabled={!monitored || !hasMonitoredEpisodes || !hasEpisodes}
|
||||
isSpinning={isSearching}
|
||||
title={
|
||||
hasMonitoredEpisodes
|
||||
? undefined
|
||||
: translate('NoMonitoredEpisodes')
|
||||
}
|
||||
onPress={handleSearchPress}
|
||||
/>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.titleContainer}>
|
||||
<div className={styles.toggleMonitoredContainer}>
|
||||
<MonitorToggleButton
|
||||
className={styles.monitorToggleButton}
|
||||
monitored={monitored}
|
||||
isSaving={isSaving}
|
||||
size={40}
|
||||
onPress={handleMonitorTogglePress}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
<div className={styles.title}>{title}</div>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('PreviewRename')}
|
||||
iconName={icons.ORGANIZE}
|
||||
isDisabled={!hasEpisodeFiles}
|
||||
onPress={handleOrganizePress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('ManageEpisodes')}
|
||||
iconName={icons.EPISODE_FILE}
|
||||
onPress={handleManageEpisodesPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('History')}
|
||||
iconName={icons.HISTORY}
|
||||
isDisabled={!hasEpisodes}
|
||||
onPress={handleSeriesHistoryPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('EpisodeMonitoring')}
|
||||
iconName={icons.MONITORED}
|
||||
onPress={handleMonitorOptionsPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('Edit')}
|
||||
iconName={icons.EDIT}
|
||||
onPress={handleEditSeriesPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('Delete')}
|
||||
iconName={icons.DELETE}
|
||||
onPress={handleDeleteSeriesPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<PageToolbarButton
|
||||
label={
|
||||
expandedState.allExpanded
|
||||
? translate('CollapseAll')
|
||||
: translate('ExpandAll')
|
||||
}
|
||||
iconName={expandIcon}
|
||||
onPress={handleExpandAllPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
|
||||
<PageContentBody innerClassName={styles.innerContentBody}>
|
||||
<div className={styles.header}>
|
||||
<div
|
||||
className={styles.backdrop}
|
||||
style={
|
||||
fanartUrl ? { backgroundImage: `url(${fanartUrl})` } : undefined
|
||||
}
|
||||
>
|
||||
<div className={styles.backdropOverlay} />
|
||||
</div>
|
||||
|
||||
<div className={styles.headerContent}>
|
||||
<SeriesPoster
|
||||
className={styles.poster}
|
||||
images={images}
|
||||
size={500}
|
||||
lazy={false}
|
||||
/>
|
||||
|
||||
<div className={styles.info}>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.titleContainer}>
|
||||
<div className={styles.toggleMonitoredContainer}>
|
||||
<MonitorToggleButton
|
||||
className={styles.monitorToggleButton}
|
||||
monitored={monitored}
|
||||
isSaving={isSaving}
|
||||
size={40}
|
||||
onPress={handleMonitorTogglePress}
|
||||
{alternateTitles.length ? (
|
||||
<div className={styles.alternateTitlesIconContainer}>
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon name={icons.ALTERNATE_TITLES} size={20} />
|
||||
}
|
||||
title={translate('AlternateTitles')}
|
||||
body={
|
||||
<SeriesAlternateTitles
|
||||
alternateTitles={alternateTitles}
|
||||
/>
|
||||
}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.title}>{title}</div>
|
||||
|
||||
{alternateTitles.length ? (
|
||||
<div className={styles.alternateTitlesIconContainer}>
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon name={icons.ALTERNATE_TITLES} size={20} />
|
||||
}
|
||||
title={translate('AlternateTitles')}
|
||||
body={
|
||||
<SeriesAlternateTitles
|
||||
alternateTitles={alternateTitles}
|
||||
/>
|
||||
}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.seriesNavigationButtons}>
|
||||
{previousSeries ? (
|
||||
<IconButton
|
||||
className={styles.seriesNavigationButton}
|
||||
name={icons.ARROW_LEFT}
|
||||
size={30}
|
||||
title={translate('SeriesDetailsGoTo', {
|
||||
title: previousSeries.title,
|
||||
})}
|
||||
to={`/series/${previousSeries.titleSlug}`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{nextSeries ? (
|
||||
<IconButton
|
||||
className={styles.seriesNavigationButton}
|
||||
name={icons.ARROW_RIGHT}
|
||||
size={30}
|
||||
title={translate('SeriesDetailsGoTo', {
|
||||
title: nextSeries.title,
|
||||
})}
|
||||
to={`/series/${nextSeries.titleSlug}`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.details}>
|
||||
<div>
|
||||
{runtime ? (
|
||||
<span className={styles.runtime}>
|
||||
{translate('SeriesDetailsRuntime', { runtime })}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{ratings.value ? (
|
||||
<HeartRating
|
||||
rating={ratings.value}
|
||||
votes={ratings.votes}
|
||||
iconSize={20}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SeriesGenres className={styles.genres} genres={genres} />
|
||||
|
||||
<span>{runningYears}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
||||
<div>
|
||||
<Icon name={icons.FOLDER} size={17} />
|
||||
<span className={styles.path}>{path}</span>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Tooltip
|
||||
anchor={
|
||||
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
||||
<div>
|
||||
<Icon name={icons.DRIVE} size={17} />
|
||||
|
||||
<span className={styles.sizeOnDisk}>
|
||||
{formatBytes(sizeOnDisk)}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
}
|
||||
tooltip={<span>{episodeFilesCountMessage}</span>}
|
||||
kind={kinds.INVERSE}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
title={translate('QualityProfile')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<div>
|
||||
<Icon name={icons.PROFILE} size={17} />
|
||||
<span className={styles.qualityProfileName}>
|
||||
<QualityProfileName
|
||||
qualityProfileId={qualityProfileId}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
||||
<div>
|
||||
<Icon
|
||||
name={monitored ? icons.MONITORED : icons.UNMONITORED}
|
||||
size={17}
|
||||
/>
|
||||
<span className={styles.qualityProfileName}>
|
||||
{monitored
|
||||
? translate('Monitored')
|
||||
: translate('Unmonitored')}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
title={statusDetails.message}
|
||||
size={sizes.LARGE}
|
||||
kind={status === 'deleted' ? kinds.INVERSE : undefined}
|
||||
>
|
||||
<div>
|
||||
<Icon name={statusDetails.icon} size={17} />
|
||||
<span className={styles.statusName}>
|
||||
{statusDetails.title}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
{originalLanguage?.name ? (
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
title={translate('OriginalLanguage')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<div>
|
||||
<Icon name={icons.LANGUAGE} size={17} />
|
||||
<span className={styles.originalLanguageName}>
|
||||
{originalLanguage.name}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
) : null}
|
||||
|
||||
{network ? (
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
title={translate('Network')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<div>
|
||||
<Icon name={icons.NETWORK} size={17} />
|
||||
<span className={styles.network}>{network}</span>
|
||||
</div>
|
||||
</Label>
|
||||
) : null}
|
||||
|
||||
<Tooltip
|
||||
anchor={
|
||||
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
||||
<div>
|
||||
<Icon name={icons.EXTERNAL_LINK} size={17} />
|
||||
<span className={styles.links}>
|
||||
{translate('Links')}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
}
|
||||
tooltip={
|
||||
<SeriesDetailsLinks
|
||||
tvdbId={tvdbId}
|
||||
tvMazeId={tvMazeId}
|
||||
imdbId={imdbId}
|
||||
tmdbId={tmdbId}
|
||||
/>
|
||||
}
|
||||
kind={kinds.INVERSE}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
|
||||
{tags.length ? (
|
||||
<Tooltip
|
||||
anchor={
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<Icon name={icons.TAGS} size={17} />
|
||||
|
||||
<span className={styles.tags}>
|
||||
{translate('Tags')}
|
||||
</span>
|
||||
</Label>
|
||||
}
|
||||
tooltip={<SeriesTags seriesId={seriesId} />}
|
||||
kind={kinds.INVERSE}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
<div className={styles.seriesNavigationButtons}>
|
||||
{previousSeries ? (
|
||||
<IconButton
|
||||
className={styles.seriesNavigationButton}
|
||||
name={icons.ARROW_LEFT}
|
||||
size={30}
|
||||
title={translate('SeriesDetailsGoTo', {
|
||||
title: previousSeries.title,
|
||||
})}
|
||||
to={`/series/${previousSeries.titleSlug}`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SeriesProgressLabel
|
||||
className={styles.seriesProgressLabel}
|
||||
seriesId={seriesId}
|
||||
monitored={monitored}
|
||||
episodeCount={episodeCount}
|
||||
episodeFileCount={episodeFileCount}
|
||||
/>
|
||||
{nextSeries ? (
|
||||
<IconButton
|
||||
className={styles.seriesNavigationButton}
|
||||
name={icons.ARROW_RIGHT}
|
||||
size={30}
|
||||
title={translate('SeriesDetailsGoTo', {
|
||||
title: nextSeries.title,
|
||||
})}
|
||||
to={`/series/${nextSeries.titleSlug}`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.overview}>{overview}</div>
|
||||
|
||||
<MetadataAttribution />
|
||||
</div>
|
||||
|
||||
<div className={styles.details}>
|
||||
<div>
|
||||
{runtime ? (
|
||||
<span className={styles.runtime}>
|
||||
{translate('SeriesDetailsRuntime', { runtime })}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{ratings.value ? (
|
||||
<HeartRating
|
||||
rating={ratings.value}
|
||||
votes={ratings.votes}
|
||||
iconSize={20}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SeriesGenres className={styles.genres} genres={genres} />
|
||||
|
||||
<span>{runningYears}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
||||
<div>
|
||||
<Icon name={icons.FOLDER} size={17} />
|
||||
<span className={styles.path}>{path}</span>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Tooltip
|
||||
anchor={
|
||||
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
||||
<div>
|
||||
<Icon name={icons.DRIVE} size={17} />
|
||||
|
||||
<span className={styles.sizeOnDisk}>
|
||||
{formatBytes(sizeOnDisk)}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
}
|
||||
tooltip={<span>{episodeFilesCountMessage}</span>}
|
||||
kind={kinds.INVERSE}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
title={translate('QualityProfile')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<div>
|
||||
<Icon name={icons.PROFILE} size={17} />
|
||||
<span className={styles.qualityProfileName}>
|
||||
<QualityProfileName qualityProfileId={qualityProfileId} />
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
||||
<div>
|
||||
<Icon
|
||||
name={monitored ? icons.MONITORED : icons.UNMONITORED}
|
||||
size={17}
|
||||
/>
|
||||
<span className={styles.qualityProfileName}>
|
||||
{monitored
|
||||
? translate('Monitored')
|
||||
: translate('Unmonitored')}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
title={statusDetails.message}
|
||||
size={sizes.LARGE}
|
||||
kind={status === 'deleted' ? kinds.INVERSE : undefined}
|
||||
>
|
||||
<div>
|
||||
<Icon name={statusDetails.icon} size={17} />
|
||||
<span className={styles.statusName}>
|
||||
{statusDetails.title}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
{originalLanguage?.name ? (
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
title={translate('OriginalLanguage')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<div>
|
||||
<Icon name={icons.LANGUAGE} size={17} />
|
||||
<span className={styles.originalLanguageName}>
|
||||
{originalLanguage.name}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
) : null}
|
||||
|
||||
{network ? (
|
||||
<Label
|
||||
className={styles.detailsLabel}
|
||||
title={translate('Network')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<div>
|
||||
<Icon name={icons.NETWORK} size={17} />
|
||||
<span className={styles.network}>{network}</span>
|
||||
</div>
|
||||
</Label>
|
||||
) : null}
|
||||
|
||||
<Tooltip
|
||||
anchor={
|
||||
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
||||
<div>
|
||||
<Icon name={icons.EXTERNAL_LINK} size={17} />
|
||||
<span className={styles.links}>
|
||||
{translate('Links')}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
}
|
||||
tooltip={
|
||||
<SeriesDetailsLinks
|
||||
tvdbId={tvdbId}
|
||||
tvMazeId={tvMazeId}
|
||||
imdbId={imdbId}
|
||||
tmdbId={tmdbId}
|
||||
/>
|
||||
}
|
||||
kind={kinds.INVERSE}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
|
||||
{tags.length ? (
|
||||
<Tooltip
|
||||
anchor={
|
||||
<Label className={styles.detailsLabel} size={sizes.LARGE}>
|
||||
<Icon name={icons.TAGS} size={17} />
|
||||
|
||||
<span className={styles.tags}>{translate('Tags')}</span>
|
||||
</Label>
|
||||
}
|
||||
tooltip={<SeriesTags seriesId={seriesId} />}
|
||||
kind={kinds.INVERSE}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SeriesProgressLabel
|
||||
className={styles.seriesProgressLabel}
|
||||
seriesId={seriesId}
|
||||
monitored={monitored}
|
||||
episodeCount={episodeCount}
|
||||
episodeFileCount={episodeFileCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.overview}>{overview}</div>
|
||||
|
||||
<MetadataAttribution />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.contentContainer}>
|
||||
{!isPopulated && !episodesError && !episodeFilesError ? (
|
||||
<LoadingIndicator />
|
||||
) : null}
|
||||
<div className={styles.contentContainer}>
|
||||
{!isPopulated && !episodesError && !episodeFilesError ? (
|
||||
<LoadingIndicator />
|
||||
) : null}
|
||||
|
||||
{!isFetching && episodesError ? (
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('EpisodesLoadError')}
|
||||
</Alert>
|
||||
) : null}
|
||||
{!isFetching && episodesError ? (
|
||||
<Alert kind={kinds.DANGER}>{translate('EpisodesLoadError')}</Alert>
|
||||
) : null}
|
||||
|
||||
{!isFetching && episodeFilesError ? (
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('EpisodeFilesLoadError')}
|
||||
</Alert>
|
||||
) : null}
|
||||
{!isFetching && episodeFilesError ? (
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('EpisodeFilesLoadError')}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{isPopulated && !!seasons.length ? (
|
||||
<div>
|
||||
{seasons
|
||||
.slice(0)
|
||||
.reverse()
|
||||
.map((season) => {
|
||||
return (
|
||||
<SeriesDetailsSeason
|
||||
key={season.seasonNumber}
|
||||
seriesId={seriesId}
|
||||
{...season}
|
||||
isExpanded={expandedState.seasons[season.seasonNumber]}
|
||||
onExpandPress={handleExpandPress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{isPopulated && !!seasons.length ? (
|
||||
<div>
|
||||
{seasons
|
||||
.slice(0)
|
||||
.reverse()
|
||||
.map((season) => {
|
||||
return (
|
||||
<SeriesDetailsSeason
|
||||
key={season.seasonNumber}
|
||||
seriesId={seriesId}
|
||||
{...season}
|
||||
isExpanded={expandedState.seasons[season.seasonNumber]}
|
||||
onExpandPress={handleExpandPress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isPopulated && !seasons.length ? (
|
||||
<Alert kind={kinds.WARNING}>
|
||||
{translate('NoEpisodeInformation')}
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
{isPopulated && !seasons.length ? (
|
||||
<Alert kind={kinds.WARNING}>
|
||||
{translate('NoEpisodeInformation')}
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<OrganizePreviewModal
|
||||
isOpen={isOrganizeModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleOrganizeModalClose}
|
||||
/>
|
||||
<OrganizePreviewModal
|
||||
isOpen={isOrganizeModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleOrganizeModalClose}
|
||||
/>
|
||||
|
||||
<InteractiveImportModal
|
||||
isOpen={isManageEpisodesOpen}
|
||||
seriesId={seriesId}
|
||||
title={title}
|
||||
folder={path}
|
||||
initialSortKey="relativePath"
|
||||
initialSortDirection={sortDirections.DESCENDING}
|
||||
showSeries={false}
|
||||
allowSeriesChange={false}
|
||||
showDelete={true}
|
||||
showImportMode={false}
|
||||
modalTitle={translate('ManageEpisodes')}
|
||||
onModalClose={handleManageEpisodesModalClose}
|
||||
/>
|
||||
<InteractiveImportModal
|
||||
isOpen={isManageEpisodesOpen}
|
||||
seriesId={seriesId}
|
||||
title={title}
|
||||
folder={path}
|
||||
initialSortKey="relativePath"
|
||||
initialSortDirection={sortDirections.DESCENDING}
|
||||
showSeries={false}
|
||||
allowSeriesChange={false}
|
||||
showDelete={true}
|
||||
showImportMode={false}
|
||||
modalTitle={translate('ManageEpisodes')}
|
||||
onModalClose={handleManageEpisodesModalClose}
|
||||
/>
|
||||
|
||||
<SeriesHistoryModal
|
||||
isOpen={isSeriesHistoryModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleSeriesHistoryModalClose}
|
||||
/>
|
||||
<SeriesHistoryModal
|
||||
isOpen={isSeriesHistoryModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleSeriesHistoryModalClose}
|
||||
/>
|
||||
|
||||
<EditSeriesModal
|
||||
isOpen={isEditSeriesModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleEditSeriesModalClose}
|
||||
onDeleteSeriesPress={handleDeleteSeriesPress}
|
||||
/>
|
||||
<EditSeriesModal
|
||||
isOpen={isEditSeriesModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleEditSeriesModalClose}
|
||||
onDeleteSeriesPress={handleDeleteSeriesPress}
|
||||
/>
|
||||
|
||||
<DeleteSeriesModal
|
||||
isOpen={isDeleteSeriesModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleDeleteSeriesModalClose}
|
||||
/>
|
||||
<DeleteSeriesModal
|
||||
isOpen={isDeleteSeriesModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleDeleteSeriesModalClose}
|
||||
/>
|
||||
|
||||
<MonitoringOptionsModal
|
||||
isOpen={isMonitorOptionsModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleMonitorOptionsClose}
|
||||
/>
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
</QueueDetailsProvider>
|
||||
<MonitoringOptionsModal
|
||||
isOpen={isMonitorOptionsModalOpen}
|
||||
seriesId={seriesId}
|
||||
onModalClose={handleMonitorOptionsClose}
|
||||
/>
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React from 'react';
|
||||
import { useQueueDetailsForSeries } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import createSeriesQueueItemsDetailsSelector, {
|
||||
SeriesQueueDetails,
|
||||
} from 'Series/Index/createSeriesQueueDetailsSelector';
|
||||
|
||||
function getEpisodeCountKind(
|
||||
monitored: boolean,
|
||||
@@ -39,7 +42,9 @@ function SeriesProgressLabel({
|
||||
episodeCount,
|
||||
episodeFileCount,
|
||||
}: SeriesProgressLabelProps) {
|
||||
const queueDetails = useQueueDetailsForSeries(seriesId);
|
||||
const queueDetails: SeriesQueueDetails = useSelector(
|
||||
createSeriesQueueItemsDetailsSelector(seriesId)
|
||||
);
|
||||
|
||||
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
|
||||
const text = newDownloads
|
||||
|
||||
@@ -1,7 +1,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}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React from 'react';
|
||||
import { useQueueDetailsForSeries } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import { useSelector } from 'react-redux';
|
||||
import ProgressBar from 'Components/ProgressBar';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import createSeriesQueueItemsDetailsSelector, {
|
||||
SeriesQueueDetails,
|
||||
} from 'Series/Index/createSeriesQueueDetailsSelector';
|
||||
import { SeriesStatus } from 'Series/Series';
|
||||
import getProgressBarKind from 'Utilities/Series/getProgressBarKind';
|
||||
import translate from 'Utilities/String/translate';
|
||||
@@ -34,7 +37,9 @@ function SeriesIndexProgressBar(props: SeriesIndexProgressBarProps) {
|
||||
isStandalone,
|
||||
} = props;
|
||||
|
||||
const queueDetails = useQueueDetailsForSeries(seriesId, seasonNumber);
|
||||
const queueDetails: SeriesQueueDetails = useSelector(
|
||||
createSeriesQueueItemsDetailsSelector(seriesId, seasonNumber)
|
||||
);
|
||||
|
||||
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
|
||||
const progress = episodeCount ? (episodeFileCount / episodeCount) * 100 : 100;
|
||||
|
||||
@@ -6,7 +6,6 @@ import React, {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import { SelectProvider } from 'App/SelectContext';
|
||||
import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState';
|
||||
import SeriesAppState, { SeriesIndexAppState } from 'App/State/SeriesAppState';
|
||||
@@ -27,6 +26,7 @@ import { DESCENDING } from 'Helpers/Props/sortDirections';
|
||||
import ParseToolbarButton from 'Parse/ParseToolbarButton';
|
||||
import NoSeries from 'Series/NoSeries';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { fetchQueueDetails } from 'Store/Actions/queueActions';
|
||||
import { fetchSeries } from 'Store/Actions/seriesActions';
|
||||
import {
|
||||
setSeriesFilter,
|
||||
@@ -104,6 +104,7 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchSeries());
|
||||
dispatch(fetchQueueDetails({ all: true }));
|
||||
}, [dispatch]);
|
||||
|
||||
const onRssSyncPress = useCallback(() => {
|
||||
@@ -216,159 +217,155 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
|
||||
const hasNoSeries = !totalItems;
|
||||
|
||||
return (
|
||||
<QueueDetailsProvider all={true}>
|
||||
<SelectProvider items={items}>
|
||||
<PageContent>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<SeriesIndexRefreshSeriesButton
|
||||
isSelectMode={isSelectMode}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
/>
|
||||
<SelectProvider items={items}>
|
||||
<PageContent>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<SeriesIndexRefreshSeriesButton
|
||||
isSelectMode={isSelectMode}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('RssSync')}
|
||||
iconName={icons.RSS}
|
||||
isSpinning={isRssSyncExecuting}
|
||||
isDisabled={hasNoSeries}
|
||||
onPress={onRssSyncPress}
|
||||
/>
|
||||
<PageToolbarButton
|
||||
label={translate('RssSync')}
|
||||
iconName={icons.RSS}
|
||||
isSpinning={isRssSyncExecuting}
|
||||
isDisabled={hasNoSeries}
|
||||
onPress={onRssSyncPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<SeriesIndexSelectModeButton
|
||||
label={
|
||||
isSelectMode
|
||||
? translate('StopSelecting')
|
||||
: translate('SelectSeries')
|
||||
}
|
||||
iconName={isSelectMode ? icons.SERIES_ENDED : icons.CHECK}
|
||||
isSelectMode={isSelectMode}
|
||||
overflowComponent={SeriesIndexSelectModeMenuItem}
|
||||
onPress={onSelectModePress}
|
||||
/>
|
||||
<SeriesIndexSelectModeButton
|
||||
label={
|
||||
isSelectMode
|
||||
? translate('StopSelecting')
|
||||
: translate('SelectSeries')
|
||||
}
|
||||
iconName={isSelectMode ? icons.SERIES_ENDED : icons.CHECK}
|
||||
isSelectMode={isSelectMode}
|
||||
overflowComponent={SeriesIndexSelectModeMenuItem}
|
||||
onPress={onSelectModePress}
|
||||
/>
|
||||
|
||||
<SeriesIndexSelectAllButton
|
||||
label="SelectAll"
|
||||
isSelectMode={isSelectMode}
|
||||
overflowComponent={SeriesIndexSelectAllMenuItem}
|
||||
/>
|
||||
<SeriesIndexSelectAllButton
|
||||
label="SelectAll"
|
||||
isSelectMode={isSelectMode}
|
||||
overflowComponent={SeriesIndexSelectAllMenuItem}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
<ParseToolbarButton />
|
||||
</PageToolbarSection>
|
||||
<PageToolbarSeparator />
|
||||
<ParseToolbarButton />
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection
|
||||
alignContent={align.RIGHT}
|
||||
collapseButtons={false}
|
||||
>
|
||||
{view === 'table' ? (
|
||||
<TableOptionsModalWrapper
|
||||
columns={columns}
|
||||
optionsComponent={SeriesIndexTableOptions}
|
||||
onTableOptionChange={onTableOptionChange}
|
||||
>
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={icons.TABLE}
|
||||
/>
|
||||
</TableOptionsModalWrapper>
|
||||
) : (
|
||||
<PageToolbarSection
|
||||
alignContent={align.RIGHT}
|
||||
collapseButtons={false}
|
||||
>
|
||||
{view === 'table' ? (
|
||||
<TableOptionsModalWrapper
|
||||
columns={columns}
|
||||
optionsComponent={SeriesIndexTableOptions}
|
||||
onTableOptionChange={onTableOptionChange}
|
||||
>
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW}
|
||||
isDisabled={hasNoSeries}
|
||||
onPress={onOptionsPress}
|
||||
iconName={icons.TABLE}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<SeriesIndexViewMenu
|
||||
view={view}
|
||||
</TableOptionsModalWrapper>
|
||||
) : (
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW}
|
||||
isDisabled={hasNoSeries}
|
||||
onViewSelect={onViewSelect}
|
||||
onPress={onOptionsPress}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SeriesIndexSortMenu
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
isDisabled={hasNoSeries}
|
||||
onSortSelect={onSortSelect}
|
||||
/>
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<SeriesIndexFilterMenu
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={customFilters}
|
||||
isDisabled={hasNoSeries}
|
||||
onFilterSelect={onFilterSelect}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
<div className={styles.pageContentBodyWrapper}>
|
||||
<PageContentBody
|
||||
ref={scrollerRef}
|
||||
className={styles.contentBody}
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
innerClassName={styles[`${view}InnerContentBody`]}
|
||||
initialScrollTop={props.initialScrollTop}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
|
||||
<SeriesIndexViewMenu
|
||||
view={view}
|
||||
isDisabled={hasNoSeries}
|
||||
onViewSelect={onViewSelect}
|
||||
/>
|
||||
|
||||
{!isFetching && !!error ? (
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('SeriesLoadError')}
|
||||
</Alert>
|
||||
) : null}
|
||||
<SeriesIndexSortMenu
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
isDisabled={hasNoSeries}
|
||||
onSortSelect={onSortSelect}
|
||||
/>
|
||||
|
||||
{isLoaded ? (
|
||||
<div className={styles.contentBodyContainer}>
|
||||
<ViewComponent
|
||||
scrollerRef={scrollerRef}
|
||||
items={items}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
jumpToCharacter={jumpToCharacter}
|
||||
isSelectMode={isSelectMode}
|
||||
isSmallScreen={isSmallScreen}
|
||||
/>
|
||||
<SeriesIndexFilterMenu
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={customFilters}
|
||||
isDisabled={hasNoSeries}
|
||||
onFilterSelect={onFilterSelect}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
<div className={styles.pageContentBodyWrapper}>
|
||||
<PageContentBody
|
||||
ref={scrollerRef}
|
||||
className={styles.contentBody}
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
innerClassName={styles[`${view}InnerContentBody`]}
|
||||
initialScrollTop={props.initialScrollTop}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
|
||||
|
||||
<SeriesIndexFooter />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!error && isPopulated && !items.length ? (
|
||||
<NoSeries totalItems={totalItems} />
|
||||
) : null}
|
||||
</PageContentBody>
|
||||
{isLoaded && !!jumpBarItems.order.length ? (
|
||||
<PageJumpBar
|
||||
items={jumpBarItems}
|
||||
onItemPress={onJumpBarItemPress}
|
||||
/>
|
||||
{!isFetching && !!error ? (
|
||||
<Alert kind={kinds.DANGER}>{translate('SeriesLoadError')}</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isSelectMode ? <SeriesIndexSelectFooter /> : null}
|
||||
{isLoaded ? (
|
||||
<div className={styles.contentBodyContainer}>
|
||||
<ViewComponent
|
||||
scrollerRef={scrollerRef}
|
||||
items={items}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
jumpToCharacter={jumpToCharacter}
|
||||
isSelectMode={isSelectMode}
|
||||
isSmallScreen={isSmallScreen}
|
||||
/>
|
||||
|
||||
{view === 'posters' ? (
|
||||
<SeriesIndexPosterOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={onOptionsModalClose}
|
||||
<SeriesIndexFooter />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!error && isPopulated && !items.length ? (
|
||||
<NoSeries totalItems={totalItems} />
|
||||
) : null}
|
||||
</PageContentBody>
|
||||
{isLoaded && !!jumpBarItems.order.length ? (
|
||||
<PageJumpBar
|
||||
items={jumpBarItems}
|
||||
onItemPress={onJumpBarItemPress}
|
||||
/>
|
||||
) : null}
|
||||
{view === 'overview' ? (
|
||||
<SeriesIndexOverviewOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={onOptionsModalClose}
|
||||
/>
|
||||
) : null}
|
||||
</PageContent>
|
||||
</SelectProvider>
|
||||
</QueueDetailsProvider>
|
||||
</div>
|
||||
|
||||
{isSelectMode ? <SeriesIndexSelectFooter /> : null}
|
||||
|
||||
{view === 'posters' ? (
|
||||
<SeriesIndexPosterOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={onOptionsModalClose}
|
||||
/>
|
||||
) : null}
|
||||
{view === 'overview' ? (
|
||||
<SeriesIndexOverviewOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={onOptionsModalClose}
|
||||
/>
|
||||
) : null}
|
||||
</PageContent>
|
||||
</SelectProvider>
|
||||
);
|
||||
}, 'seriesIndex');
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
|
||||
export interface SeriesQueueDetails {
|
||||
count: number;
|
||||
episodesWithFiles: number;
|
||||
}
|
||||
|
||||
function createSeriesQueueDetailsSelector(
|
||||
seriesId: number,
|
||||
seasonNumber?: number
|
||||
) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.queue.details.items,
|
||||
(queueItems) => {
|
||||
return queueItems.reduce(
|
||||
(acc: SeriesQueueDetails, item) => {
|
||||
if (
|
||||
item.trackedDownloadState === 'imported' ||
|
||||
item.seriesId !== seriesId
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (seasonNumber != null && item.seasonNumber !== seasonNumber) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.count++;
|
||||
|
||||
if (item.episodeHasFile) {
|
||||
acc.episodesWithFiles++;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
count: 0,
|
||||
episodesWithFiles: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default createSeriesQueueDetailsSelector;
|
||||
@@ -171,7 +171,7 @@ function HostSettings({
|
||||
</FormGroup>
|
||||
) : null}
|
||||
|
||||
{isWindowsService ? null : (
|
||||
{isWindowsService ? (
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>{translate('OpenBrowserOnStart')}</FormLabel>
|
||||
|
||||
@@ -183,7 +183,7 @@ function HostSettings({
|
||||
{...launchBrowser}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
) : null}
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import * as organizePreview from './organizePreviewActions';
|
||||
import * as parse from './parseActions';
|
||||
import * as paths from './pathActions';
|
||||
import * as providerOptions from './providerOptionActions';
|
||||
import * as queue from './queueActions';
|
||||
import * as releases from './releaseActions';
|
||||
import * as rootFolders from './rootFolderActions';
|
||||
import * as series from './seriesActions';
|
||||
@@ -45,6 +46,7 @@ export default [
|
||||
parse,
|
||||
paths,
|
||||
providerOptions,
|
||||
queue,
|
||||
releases,
|
||||
rootFolders,
|
||||
series,
|
||||
|
||||
562
frontend/src/Store/Actions/queueActions.js
Normal file
562
frontend/src/Store/Actions/queueActions.js
Normal file
@@ -0,0 +1,562 @@
|
||||
import _ from 'lodash';
|
||||
import React from 'react';
|
||||
import { createAction } from 'redux-actions';
|
||||
import { batchActions } from 'redux-batched-actions';
|
||||
import Icon from 'Components/Icon';
|
||||
import { filterBuilderTypes, filterBuilderValueTypes, icons, sortDirections } from 'Helpers/Props';
|
||||
import { createThunk, handleThunks } from 'Store/thunks';
|
||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
||||
import serverSideCollectionHandlers from 'Utilities/State/serverSideCollectionHandlers';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { set, updateItem } from './baseActions';
|
||||
import createFetchHandler from './Creators/createFetchHandler';
|
||||
import createHandleActions from './Creators/createHandleActions';
|
||||
import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
|
||||
import createClearReducer from './Creators/Reducers/createClearReducer';
|
||||
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
|
||||
|
||||
//
|
||||
// Variables
|
||||
|
||||
export const section = 'queue';
|
||||
const status = `${section}.status`;
|
||||
const details = `${section}.details`;
|
||||
const paged = `${section}.paged`;
|
||||
|
||||
//
|
||||
// State
|
||||
|
||||
export const defaultState = {
|
||||
options: {
|
||||
includeUnknownSeriesItems: true
|
||||
},
|
||||
|
||||
removalOptions: {
|
||||
removalMethod: 'removeFromClient',
|
||||
blocklistMethod: 'doNotBlocklist'
|
||||
},
|
||||
|
||||
status: {
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: null,
|
||||
item: {}
|
||||
},
|
||||
|
||||
details: {
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: null,
|
||||
items: [],
|
||||
params: {}
|
||||
},
|
||||
|
||||
paged: {
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
pageSize: 20,
|
||||
sortKey: 'timeleft',
|
||||
sortDirection: sortDirections.ASCENDING,
|
||||
error: null,
|
||||
items: [],
|
||||
isGrabbing: false,
|
||||
isRemoving: false,
|
||||
|
||||
columns: [
|
||||
{
|
||||
name: 'status',
|
||||
columnLabel: () => translate('Status'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
isModifiable: false
|
||||
},
|
||||
{
|
||||
name: 'series.sortTitle',
|
||||
label: () => translate('Series'),
|
||||
isSortable: true,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'episode',
|
||||
label: () => translate('Episode'),
|
||||
isSortable: true,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'episodes.title',
|
||||
label: () => translate('EpisodeTitle'),
|
||||
isSortable: true,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'episodes.airDateUtc',
|
||||
label: () => translate('EpisodeAirDate'),
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
label: () => translate('Languages'),
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: () => translate('Quality'),
|
||||
isSortable: true,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'customFormats',
|
||||
label: () => translate('Formats'),
|
||||
isSortable: false,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'customFormatScore',
|
||||
columnLabel: () => translate('CustomFormatScore'),
|
||||
label: React.createElement(Icon, {
|
||||
name: icons.SCORE,
|
||||
title: () => translate('CustomFormatScore')
|
||||
}),
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'protocol',
|
||||
label: () => translate('Protocol'),
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'indexer',
|
||||
label: () => translate('Indexer'),
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'downloadClient',
|
||||
label: () => translate('DownloadClient'),
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
label: () => translate('ReleaseTitle'),
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'size',
|
||||
label: () => translate('Size'),
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'outputPath',
|
||||
label: () => translate('OutputPath'),
|
||||
isSortable: false,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'estimatedCompletionTime',
|
||||
label: () => translate('TimeLeft'),
|
||||
isSortable: true,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'added',
|
||||
label: () => translate('Added'),
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
name: 'progress',
|
||||
label: () => translate('Progress'),
|
||||
isSortable: true,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
columnLabel: () => translate('Actions'),
|
||||
isVisible: true,
|
||||
isModifiable: false
|
||||
}
|
||||
],
|
||||
|
||||
selectedFilterKey: 'all',
|
||||
|
||||
filters: [
|
||||
{
|
||||
key: 'all',
|
||||
label: 'All',
|
||||
filters: []
|
||||
}
|
||||
],
|
||||
|
||||
filterBuilderProps: [
|
||||
{
|
||||
name: 'seriesIds',
|
||||
label: () => translate('Series'),
|
||||
type: filterBuilderTypes.EQUAL,
|
||||
valueType: filterBuilderValueTypes.SERIES
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: () => translate('Quality'),
|
||||
type: filterBuilderTypes.EQUAL,
|
||||
valueType: filterBuilderValueTypes.QUALITY
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
label: () => translate('Languages'),
|
||||
type: filterBuilderTypes.CONTAINS,
|
||||
valueType: filterBuilderValueTypes.LANGUAGE
|
||||
},
|
||||
{
|
||||
name: 'protocol',
|
||||
label: () => translate('Protocol'),
|
||||
type: filterBuilderTypes.EQUAL,
|
||||
valueType: filterBuilderValueTypes.PROTOCOL
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
label: () => translate('Status'),
|
||||
type: filterBuilderTypes.EQUAL,
|
||||
valueType: filterBuilderValueTypes.QUEUE_STATUS
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export const persistState = [
|
||||
'queue.options',
|
||||
'queue.removalOptions',
|
||||
'queue.paged.pageSize',
|
||||
'queue.paged.sortKey',
|
||||
'queue.paged.sortDirection',
|
||||
'queue.paged.columns',
|
||||
'queue.paged.selectedFilterKey'
|
||||
];
|
||||
|
||||
//
|
||||
// Helpers
|
||||
|
||||
function fetchDataAugmenter(getState, payload, data) {
|
||||
data.includeUnknownSeriesItems = getState().queue.options.includeUnknownSeriesItems;
|
||||
}
|
||||
|
||||
//
|
||||
// Actions Types
|
||||
|
||||
export const FETCH_QUEUE_STATUS = 'queue/fetchQueueStatus';
|
||||
|
||||
export const FETCH_QUEUE_DETAILS = 'queue/fetchQueueDetails';
|
||||
export const CLEAR_QUEUE_DETAILS = 'queue/clearQueueDetails';
|
||||
|
||||
export const FETCH_QUEUE = 'queue/fetchQueue';
|
||||
export const GOTO_FIRST_QUEUE_PAGE = 'queue/gotoQueueFirstPage';
|
||||
export const GOTO_PREVIOUS_QUEUE_PAGE = 'queue/gotoQueuePreviousPage';
|
||||
export const GOTO_NEXT_QUEUE_PAGE = 'queue/gotoQueueNextPage';
|
||||
export const GOTO_LAST_QUEUE_PAGE = 'queue/gotoQueueLastPage';
|
||||
export const GOTO_QUEUE_PAGE = 'queue/gotoQueuePage';
|
||||
export const SET_QUEUE_SORT = 'queue/setQueueSort';
|
||||
export const SET_QUEUE_FILTER = 'queue/setQueueFilter';
|
||||
export const SET_QUEUE_TABLE_OPTION = 'queue/setQueueTableOption';
|
||||
export const SET_QUEUE_OPTION = 'queue/setQueueOption';
|
||||
export const SET_QUEUE_REMOVAL_OPTION = 'queue/setQueueRemoveOption';
|
||||
export const CLEAR_QUEUE = 'queue/clearQueue';
|
||||
|
||||
export const GRAB_QUEUE_ITEM = 'queue/grabQueueItem';
|
||||
export const GRAB_QUEUE_ITEMS = 'queue/grabQueueItems';
|
||||
export const REMOVE_QUEUE_ITEM = 'queue/removeQueueItem';
|
||||
export const REMOVE_QUEUE_ITEMS = 'queue/removeQueueItems';
|
||||
|
||||
//
|
||||
// Action Creators
|
||||
|
||||
export const fetchQueueStatus = createThunk(FETCH_QUEUE_STATUS);
|
||||
|
||||
export const fetchQueueDetails = createThunk(FETCH_QUEUE_DETAILS);
|
||||
export const clearQueueDetails = createAction(CLEAR_QUEUE_DETAILS);
|
||||
|
||||
export const fetchQueue = createThunk(FETCH_QUEUE);
|
||||
export const gotoQueueFirstPage = createThunk(GOTO_FIRST_QUEUE_PAGE);
|
||||
export const gotoQueuePreviousPage = createThunk(GOTO_PREVIOUS_QUEUE_PAGE);
|
||||
export const gotoQueueNextPage = createThunk(GOTO_NEXT_QUEUE_PAGE);
|
||||
export const gotoQueueLastPage = createThunk(GOTO_LAST_QUEUE_PAGE);
|
||||
export const gotoQueuePage = createThunk(GOTO_QUEUE_PAGE);
|
||||
export const setQueueSort = createThunk(SET_QUEUE_SORT);
|
||||
export const setQueueFilter = createThunk(SET_QUEUE_FILTER);
|
||||
export const setQueueTableOption = createAction(SET_QUEUE_TABLE_OPTION);
|
||||
export const setQueueOption = createAction(SET_QUEUE_OPTION);
|
||||
export const setQueueRemovalOption = createAction(SET_QUEUE_REMOVAL_OPTION);
|
||||
export const clearQueue = createAction(CLEAR_QUEUE);
|
||||
|
||||
export const grabQueueItem = createThunk(GRAB_QUEUE_ITEM);
|
||||
export const grabQueueItems = createThunk(GRAB_QUEUE_ITEMS);
|
||||
export const removeQueueItem = createThunk(REMOVE_QUEUE_ITEM);
|
||||
export const removeQueueItems = createThunk(REMOVE_QUEUE_ITEMS);
|
||||
|
||||
//
|
||||
// Helpers
|
||||
|
||||
const fetchQueueDetailsHelper = createFetchHandler(details, '/queue/details');
|
||||
|
||||
//
|
||||
// Action Handlers
|
||||
|
||||
export const actionHandlers = handleThunks({
|
||||
|
||||
[FETCH_QUEUE_STATUS]: createFetchHandler(status, '/queue/status'),
|
||||
|
||||
[FETCH_QUEUE_DETAILS]: function(getState, payload, dispatch) {
|
||||
let params = payload;
|
||||
|
||||
// If the payload params are empty try to get params from state.
|
||||
|
||||
if (params && !_.isEmpty(params)) {
|
||||
dispatch(set({ section: details, params }));
|
||||
} else {
|
||||
params = getState().queue.details.params;
|
||||
}
|
||||
|
||||
// Ensure there are params before trying to fetch the queue
|
||||
// so we don't make a bad request to the server.
|
||||
|
||||
if (params && !_.isEmpty(params)) {
|
||||
fetchQueueDetailsHelper(getState, params, dispatch);
|
||||
}
|
||||
},
|
||||
|
||||
...createServerSideCollectionHandlers(
|
||||
paged,
|
||||
'/queue',
|
||||
fetchQueue,
|
||||
{
|
||||
[serverSideCollectionHandlers.FETCH]: FETCH_QUEUE,
|
||||
[serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_QUEUE_PAGE,
|
||||
[serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_QUEUE_PAGE,
|
||||
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_QUEUE_PAGE,
|
||||
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_QUEUE_PAGE,
|
||||
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_QUEUE_PAGE,
|
||||
[serverSideCollectionHandlers.SORT]: SET_QUEUE_SORT,
|
||||
[serverSideCollectionHandlers.FILTER]: SET_QUEUE_FILTER
|
||||
},
|
||||
fetchDataAugmenter
|
||||
),
|
||||
|
||||
[GRAB_QUEUE_ITEM]: function(getState, payload, dispatch) {
|
||||
const id = payload.id;
|
||||
|
||||
dispatch(updateItem({ section: paged, id, isGrabbing: true }));
|
||||
|
||||
const promise = createAjaxRequest({
|
||||
url: `/queue/grab/${id}`,
|
||||
method: 'POST'
|
||||
}).request;
|
||||
|
||||
promise.done((data) => {
|
||||
dispatch(batchActions([
|
||||
fetchQueue(),
|
||||
|
||||
set({
|
||||
section: paged,
|
||||
isGrabbing: false,
|
||||
grabError: null
|
||||
})
|
||||
]));
|
||||
});
|
||||
|
||||
promise.fail((xhr) => {
|
||||
dispatch(updateItem({
|
||||
section: paged,
|
||||
id,
|
||||
isGrabbing: false,
|
||||
grabError: xhr
|
||||
}));
|
||||
});
|
||||
},
|
||||
|
||||
[GRAB_QUEUE_ITEMS]: function(getState, payload, dispatch) {
|
||||
const ids = payload.ids;
|
||||
|
||||
dispatch(batchActions([
|
||||
...ids.map((id) => {
|
||||
return updateItem({
|
||||
section: paged,
|
||||
id,
|
||||
isGrabbing: true
|
||||
});
|
||||
}),
|
||||
|
||||
set({
|
||||
section: paged,
|
||||
isGrabbing: true
|
||||
})
|
||||
]));
|
||||
|
||||
const promise = createAjaxRequest({
|
||||
url: '/queue/grab/bulk',
|
||||
method: 'POST',
|
||||
dataType: 'json',
|
||||
data: JSON.stringify(payload)
|
||||
}).request;
|
||||
|
||||
promise.done((data) => {
|
||||
dispatch(fetchQueue());
|
||||
|
||||
dispatch(batchActions([
|
||||
...ids.map((id) => {
|
||||
return updateItem({
|
||||
section: paged,
|
||||
id,
|
||||
isGrabbing: false,
|
||||
grabError: null
|
||||
});
|
||||
}),
|
||||
|
||||
set({
|
||||
section: paged,
|
||||
isGrabbing: false,
|
||||
grabError: null
|
||||
})
|
||||
]));
|
||||
});
|
||||
|
||||
promise.fail((xhr) => {
|
||||
dispatch(batchActions([
|
||||
...ids.map((id) => {
|
||||
return updateItem({
|
||||
section: paged,
|
||||
id,
|
||||
isGrabbing: false,
|
||||
grabError: null
|
||||
});
|
||||
}),
|
||||
|
||||
set({ section: paged, isGrabbing: false })
|
||||
]));
|
||||
});
|
||||
},
|
||||
|
||||
[REMOVE_QUEUE_ITEM]: function(getState, payload, dispatch) {
|
||||
const {
|
||||
id,
|
||||
remove,
|
||||
blocklist,
|
||||
skipRedownload,
|
||||
changeCategory
|
||||
} = payload;
|
||||
|
||||
dispatch(updateItem({ section: paged, id, isRemoving: true }));
|
||||
|
||||
const promise = createAjaxRequest({
|
||||
url: `/queue/${id}?removeFromClient=${remove}&blocklist=${blocklist}&skipRedownload=${skipRedownload}&changeCategory=${changeCategory}`,
|
||||
method: 'DELETE'
|
||||
}).request;
|
||||
|
||||
promise.done((data) => {
|
||||
dispatch(fetchQueue());
|
||||
});
|
||||
|
||||
promise.fail((xhr) => {
|
||||
dispatch(updateItem({ section: paged, id, isRemoving: false }));
|
||||
});
|
||||
},
|
||||
|
||||
[REMOVE_QUEUE_ITEMS]: function(getState, payload, dispatch) {
|
||||
const {
|
||||
ids,
|
||||
remove,
|
||||
blocklist,
|
||||
skipRedownload,
|
||||
changeCategory
|
||||
} = payload;
|
||||
|
||||
dispatch(batchActions([
|
||||
...ids.map((id) => {
|
||||
return updateItem({
|
||||
section: paged,
|
||||
id,
|
||||
isRemoving: true
|
||||
});
|
||||
}),
|
||||
|
||||
set({ section: paged, isRemoving: true })
|
||||
]));
|
||||
|
||||
const promise = createAjaxRequest({
|
||||
url: `/queue/bulk?removeFromClient=${remove}&blocklist=${blocklist}&skipRedownload=${skipRedownload}&changeCategory=${changeCategory}`,
|
||||
method: 'DELETE',
|
||||
dataType: 'json',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({ ids })
|
||||
}).request;
|
||||
|
||||
promise.done((data) => {
|
||||
// Don't use batchActions with thunks
|
||||
dispatch(fetchQueue());
|
||||
|
||||
dispatch(set({ section: paged, isRemoving: false }));
|
||||
});
|
||||
|
||||
promise.fail((xhr) => {
|
||||
dispatch(batchActions([
|
||||
...ids.map((id) => {
|
||||
return updateItem({
|
||||
section: paged,
|
||||
id,
|
||||
isRemoving: false
|
||||
});
|
||||
}),
|
||||
|
||||
set({ section: paged, isRemoving: false })
|
||||
]));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
//
|
||||
// Reducers
|
||||
|
||||
export const reducers = createHandleActions({
|
||||
|
||||
[CLEAR_QUEUE_DETAILS]: createClearReducer(details, defaultState.details),
|
||||
|
||||
[SET_QUEUE_TABLE_OPTION]: createSetTableOptionReducer(paged),
|
||||
|
||||
[SET_QUEUE_OPTION]: function(state, { payload }) {
|
||||
const queueOptions = state.options;
|
||||
|
||||
return {
|
||||
...state,
|
||||
options: {
|
||||
...queueOptions,
|
||||
...payload
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
[SET_QUEUE_REMOVAL_OPTION]: function(state, { payload }) {
|
||||
const queueRemovalOptions = state.removalOptions;
|
||||
|
||||
return {
|
||||
...state,
|
||||
removalOptions: {
|
||||
...queueRemovalOptions,
|
||||
...payload
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
[CLEAR_QUEUE]: createClearReducer(paged, {
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: null,
|
||||
items: [],
|
||||
totalPages: 0,
|
||||
totalRecords: 0
|
||||
})
|
||||
|
||||
}, defaultState, section);
|
||||
|
||||
31
frontend/src/Store/Selectors/createQueueItemSelector.ts
Normal file
31
frontend/src/Store/Selectors/createQueueItemSelector.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
|
||||
export function createQueueItemSelectorForHook(episodeId: number) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.queue.details.items,
|
||||
(details) => {
|
||||
if (!episodeId || !details) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return details.find((item) => item.episodeId === episodeId);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createQueueItemSelector() {
|
||||
return createSelector(
|
||||
(_: AppState, { episodeId }: { episodeId: number }) => episodeId,
|
||||
(state: AppState) => state.queue.details.items,
|
||||
(episodeId, details) => {
|
||||
if (!episodeId || !details) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return details.find((item) => item.episodeId === episodeId);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default createQueueItemSelector;
|
||||
@@ -1,5 +1,5 @@
|
||||
import Column from 'Components/Table/Column';
|
||||
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
|
||||
import { createPersist, mergeColumns } from 'Helpers/createPersist';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface EventOptions {
|
||||
columns: Column[];
|
||||
}
|
||||
|
||||
const { useOptions, setOptions, setOption } = createOptionsStore<EventOptions>(
|
||||
const eventOptionsStore = createPersist<EventOptions>(
|
||||
'event_options',
|
||||
() => {
|
||||
return {
|
||||
@@ -57,9 +57,29 @@ const { useOptions, setOptions, setOption } = createOptionsStore<EventOptions>(
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
{
|
||||
merge: mergeColumns,
|
||||
}
|
||||
);
|
||||
|
||||
export const useEventOptions = useOptions;
|
||||
export const setEventOptions = setOptions;
|
||||
export const setEventOption = setOption;
|
||||
export const useEventOptions = () => {
|
||||
return eventOptionsStore((state) => state);
|
||||
};
|
||||
|
||||
export const setEventOptions = (options: Partial<EventOptions>) => {
|
||||
eventOptionsStore.setState((state) => ({
|
||||
...state,
|
||||
...options,
|
||||
}));
|
||||
};
|
||||
|
||||
export const setEventOption = <K extends keyof EventOptions>(
|
||||
key: K,
|
||||
value: EventOptions[K]
|
||||
) => {
|
||||
eventOptionsStore.setState((state) => ({
|
||||
...state,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -81,10 +81,6 @@ async function fetchJson<T, TData>({
|
||||
throw new ApiError(path, response.status, response.statusText, body);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
return response.json() as T;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { PropertyFilter } from 'App/State/AppState';
|
||||
|
||||
export interface QueryParams {
|
||||
[key: string]:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| PropertyFilter[]
|
||||
| number[]
|
||||
| undefined;
|
||||
[key: string]: string | number | boolean | PropertyFilter[] | undefined;
|
||||
}
|
||||
|
||||
const getQueryString = (queryParams?: QueryParams) => {
|
||||
@@ -15,34 +9,27 @@ const getQueryString = (queryParams?: QueryParams) => {
|
||||
return '';
|
||||
}
|
||||
|
||||
const searchParams = Object.keys(queryParams).reduce<URLSearchParams>(
|
||||
(acc, key) => {
|
||||
const value = queryParams[key];
|
||||
|
||||
if (value == null) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (typeof value[0] === 'object') {
|
||||
(value as PropertyFilter[]).forEach((filter) => {
|
||||
acc.append(filter.key, String(filter.value));
|
||||
});
|
||||
} else {
|
||||
value.forEach((item) => {
|
||||
acc.append(key, String(item));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
acc.append(key, String(value));
|
||||
}
|
||||
const filteredParams = Object.keys(queryParams).reduce<
|
||||
Record<string, string>
|
||||
>((acc, key) => {
|
||||
const value = queryParams[key];
|
||||
|
||||
if (value == null) {
|
||||
return acc;
|
||||
},
|
||||
new URLSearchParams()
|
||||
);
|
||||
}
|
||||
|
||||
const paramsString = searchParams.toString();
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((filter) => {
|
||||
acc[filter.key] = String(filter.value);
|
||||
});
|
||||
} else {
|
||||
acc[key] = String(value);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const paramsString = new URLSearchParams(filteredParams).toString();
|
||||
|
||||
return `?${paramsString}`;
|
||||
};
|
||||
|
||||
@@ -1,25 +1,13 @@
|
||||
import KeysMatching from 'typings/Helpers/KeysMatching';
|
||||
|
||||
function selectUniqueIds<T, K>(items: T[], idProp: KeysMatching<T, K>) {
|
||||
const result = items.reduce((acc: Set<K>, item) => {
|
||||
if (!item[idProp]) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const value = item[idProp] as K;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => {
|
||||
acc.add(v);
|
||||
});
|
||||
} else {
|
||||
acc.add(value);
|
||||
return items.reduce((acc: K[], item) => {
|
||||
if (item[idProp] && acc.indexOf(item[idProp] as K) === -1) {
|
||||
acc.push(item[idProp] as K);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, new Set<K>());
|
||||
|
||||
return Array.from(result);
|
||||
}, []);
|
||||
}
|
||||
|
||||
export default selectUniqueIds;
|
||||
|
||||
@@ -29,8 +29,8 @@ interface Queue extends ModelBase {
|
||||
customFormatScore: number;
|
||||
size: number;
|
||||
title: string;
|
||||
sizeLeft: number;
|
||||
timeLeft: string;
|
||||
sizeleft: number;
|
||||
timeleft: string;
|
||||
estimatedCompletionTime: string;
|
||||
added?: string;
|
||||
status: string;
|
||||
@@ -45,11 +45,8 @@ interface Queue extends ModelBase {
|
||||
episodeHasFile: boolean;
|
||||
seriesId?: number;
|
||||
episodeId?: number;
|
||||
episodeIds: number[];
|
||||
seasonNumber?: number;
|
||||
seasonNumbers: number[];
|
||||
downloadClientHasPostImportCategory: boolean;
|
||||
isFullSeason: boolean;
|
||||
episode?: Episode;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
|
||||
{
|
||||
AddPending(id: 1, seasonNumber: 2, episodes: new[] { 3 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31($"pending-{1}");
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItems(queueId);
|
||||
|
||||
@@ -82,7 +82,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
|
||||
AddPending(id: 3, seasonNumber: 2, episodes: new[] { 3 });
|
||||
AddPending(id: 4, seasonNumber: 2, episodes: new[] { 3 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31($"pending-{3}");
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 3, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItems(queueId);
|
||||
|
||||
@@ -97,7 +97,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
|
||||
AddPending(id: 3, seasonNumber: 3, episodes: new[] { 1 });
|
||||
AddPending(id: 4, seasonNumber: 3, episodes: new[] { 1 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31($"pending-{1}");
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItems(queueId);
|
||||
|
||||
@@ -112,7 +112,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
|
||||
AddPending(id: 3, seasonNumber: 2, episodes: new[] { 2 });
|
||||
AddPending(id: 4, seasonNumber: 2, episodes: new[] { 3 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31($"pending-{1}");
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItems(queueId);
|
||||
|
||||
@@ -125,7 +125,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
|
||||
AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 });
|
||||
AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1, 2 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31($"pending-{1}");
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItems(queueId);
|
||||
|
||||
@@ -138,7 +138,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
|
||||
AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 });
|
||||
AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1, 2 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31($"pending-{2}");
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 2, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItems(queueId);
|
||||
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FizzWare.NBuilder;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Common.Crypto;
|
||||
using NzbDrone.Core.Download.Pending;
|
||||
using NzbDrone.Core.Parser;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
using NzbDrone.Core.Tv;
|
||||
|
||||
namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class RemovePendingObsoleteFixture : CoreTest<PendingReleaseService>
|
||||
{
|
||||
private List<PendingRelease> _pending;
|
||||
private Episode _episode;
|
||||
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
_pending = new List<PendingRelease>();
|
||||
|
||||
_episode = Builder<Episode>.CreateNew()
|
||||
.Build();
|
||||
|
||||
Mocker.GetMock<IPendingReleaseRepository>()
|
||||
.Setup(s => s.AllBySeriesId(It.IsAny<int>()))
|
||||
.Returns(_pending);
|
||||
|
||||
Mocker.GetMock<IPendingReleaseRepository>()
|
||||
.Setup(s => s.All())
|
||||
.Returns(_pending);
|
||||
|
||||
Mocker.GetMock<ISeriesService>()
|
||||
.Setup(s => s.GetSeries(It.IsAny<int>()))
|
||||
.Returns(new Series());
|
||||
|
||||
Mocker.GetMock<ISeriesService>()
|
||||
.Setup(s => s.GetSeries(It.IsAny<IEnumerable<int>>()))
|
||||
.Returns(new List<Series> { new Series() });
|
||||
|
||||
Mocker.GetMock<IParsingService>()
|
||||
.Setup(s => s.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<Series>()))
|
||||
.Returns(new RemoteEpisode { Episodes = new List<Episode> { _episode } });
|
||||
|
||||
Mocker.GetMock<IParsingService>()
|
||||
.Setup(s => s.GetEpisodes(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<Series>(), It.IsAny<bool>(), null))
|
||||
.Returns(new List<Episode> { _episode });
|
||||
}
|
||||
|
||||
private void AddPending(int id, int seasonNumber, int[] episodes)
|
||||
{
|
||||
_pending.Add(new PendingRelease
|
||||
{
|
||||
Id = id,
|
||||
Title = "Series.Title.S01E05.abc-Sonarr",
|
||||
ParsedEpisodeInfo = new ParsedEpisodeInfo { SeasonNumber = seasonNumber, EpisodeNumbers = episodes },
|
||||
Release = Builder<ReleaseInfo>.CreateNew().Build()
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_remove_same_release()
|
||||
{
|
||||
AddPending(id: 1, seasonNumber: 2, episodes: new[] { 3 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItemsObsolete(queueId);
|
||||
|
||||
AssertRemoved(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_remove_multiple_releases_release()
|
||||
{
|
||||
AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 });
|
||||
AddPending(id: 2, seasonNumber: 2, episodes: new[] { 2 });
|
||||
AddPending(id: 3, seasonNumber: 2, episodes: new[] { 3 });
|
||||
AddPending(id: 4, seasonNumber: 2, episodes: new[] { 3 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 3, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItemsObsolete(queueId);
|
||||
|
||||
AssertRemoved(3, 4);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_remove_different_season()
|
||||
{
|
||||
AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 });
|
||||
AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1 });
|
||||
AddPending(id: 3, seasonNumber: 3, episodes: new[] { 1 });
|
||||
AddPending(id: 4, seasonNumber: 3, episodes: new[] { 1 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItemsObsolete(queueId);
|
||||
|
||||
AssertRemoved(1, 2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_remove_different_episodes()
|
||||
{
|
||||
AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 });
|
||||
AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1 });
|
||||
AddPending(id: 3, seasonNumber: 2, episodes: new[] { 2 });
|
||||
AddPending(id: 4, seasonNumber: 2, episodes: new[] { 3 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItemsObsolete(queueId);
|
||||
|
||||
AssertRemoved(1, 2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_remove_multiepisodes()
|
||||
{
|
||||
AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 });
|
||||
AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1, 2 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItemsObsolete(queueId);
|
||||
|
||||
AssertRemoved(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_remove_singleepisodes()
|
||||
{
|
||||
AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 });
|
||||
AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1, 2 });
|
||||
|
||||
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 2, _episode.Id));
|
||||
|
||||
Subject.RemovePendingQueueItemsObsolete(queueId);
|
||||
|
||||
AssertRemoved(2);
|
||||
}
|
||||
|
||||
private void AssertRemoved(params int[] ids)
|
||||
{
|
||||
Mocker.GetMock<IPendingReleaseRepository>().Verify(c => c.DeleteMany(It.Is<IEnumerable<int>>(s => s.SequenceEqual(ids))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FizzWare.NBuilder;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.Download;
|
||||
using NzbDrone.Core.Download.TrackedDownloads;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Queue;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
using NzbDrone.Core.Tv;
|
||||
|
||||
namespace NzbDrone.Core.Test.QueueTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class ObsoleteQueueServiceFixture : CoreTest<ObsoleteQueueService>
|
||||
{
|
||||
private List<TrackedDownload> _trackedDownloads;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
var downloadClientInfo = Builder<DownloadClientItemClientInfo>.CreateNew().Build();
|
||||
|
||||
var downloadItem = Builder<NzbDrone.Core.Download.DownloadClientItem>.CreateNew()
|
||||
.With(v => v.RemainingTime = TimeSpan.FromSeconds(10))
|
||||
.With(v => v.DownloadClientInfo = downloadClientInfo)
|
||||
.Build();
|
||||
|
||||
var series = Builder<Series>.CreateNew()
|
||||
.Build();
|
||||
|
||||
var episodes = Builder<Episode>.CreateListOfSize(3)
|
||||
.All()
|
||||
.With(e => e.SeriesId = series.Id)
|
||||
.Build();
|
||||
|
||||
var remoteEpisode = Builder<RemoteEpisode>.CreateNew()
|
||||
.With(r => r.Series = series)
|
||||
.With(r => r.Episodes = new List<Episode>(episodes))
|
||||
.With(r => r.ParsedEpisodeInfo = new ParsedEpisodeInfo())
|
||||
.Build();
|
||||
|
||||
_trackedDownloads = Builder<TrackedDownload>.CreateListOfSize(1)
|
||||
.All()
|
||||
.With(v => v.IsTrackable = true)
|
||||
.With(v => v.DownloadItem = downloadItem)
|
||||
.With(v => v.RemoteEpisode = remoteEpisode)
|
||||
.Build()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void queue_items_should_have_id()
|
||||
{
|
||||
Subject.Handle(new TrackedDownloadRefreshedEvent(_trackedDownloads));
|
||||
|
||||
var queue = Subject.GetQueue();
|
||||
|
||||
queue.Should().HaveCount(3);
|
||||
|
||||
queue.All(v => v.Id > 0).Should().BeTrue();
|
||||
|
||||
var distinct = queue.Select(v => v.Id).Distinct().ToArray();
|
||||
|
||||
distinct.Should().HaveCount(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,9 +58,13 @@ namespace NzbDrone.Core.Test.QueueTests
|
||||
|
||||
var queue = Subject.GetQueue();
|
||||
|
||||
queue.Should().HaveCount(1);
|
||||
queue.Should().HaveCount(3);
|
||||
|
||||
queue.All(v => v.Id > 0).Should().BeTrue();
|
||||
|
||||
var distinct = queue.Select(v => v.Id).Distinct().ToArray();
|
||||
|
||||
distinct.Should().HaveCount(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%'");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 } }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 d’un 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 l’indexeur)",
|
||||
"UserRejectedExtensionsTextsExamples": "Examples : '.ext, .xyz' or 'ext,xyz'",
|
||||
"Warning": "Avertissement",
|
||||
"QualityDefinitionsSizeNotice": "Les restrictions de taille sont maintenant dans les profils de qualité",
|
||||
"UserInvokedSearch": "Recherche invoquée par l’utilisateur"
|
||||
"Menu": "Menu"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Эта настройка влияет только на отслеживание эпизодов или сезонов внутри сериала. Выбор ничего приведёт к остановке отслеживания сериала"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -2123,6 +2123,7 @@
|
||||
"Airs": "Ефіри",
|
||||
"DoneEditingSizes": "Редагування розмірів завершено",
|
||||
"IndexerSettingsFailDownloads": "Не вдалося завантажити",
|
||||
"DownloadClientUTorrentProviderMessage": "uTorrent має історію включення криптомайнерів, шкідливого програмного забезпечення та реклами. Ми наполегливо рекомендуємо вибрати інший клієнт.",
|
||||
"EditSelectedCustomFormats": "Змінити вибрані власні формати",
|
||||
"EditSizes": "Змінити розміри",
|
||||
"FailedToFetchSettings": "Не вдалося отримати налаштування",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NzbDrone.Common.Crypto;
|
||||
using NzbDrone.Core.Download.TrackedDownloads;
|
||||
using NzbDrone.Core.Languages;
|
||||
using NzbDrone.Core.Messaging.Events;
|
||||
using NzbDrone.Core.Qualities;
|
||||
using NzbDrone.Core.Tv;
|
||||
|
||||
#pragma warning disable CS0612
|
||||
namespace NzbDrone.Core.Queue
|
||||
{
|
||||
public interface IObsoleteQueueService
|
||||
{
|
||||
List<Queue> GetQueue();
|
||||
Queue Find(int id);
|
||||
void Remove(int id);
|
||||
}
|
||||
|
||||
public class ObsoleteQueueService : IObsoleteQueueService, IHandle<TrackedDownloadRefreshedEvent>
|
||||
{
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
private static List<Queue> _queue = new();
|
||||
|
||||
public ObsoleteQueueService(IEventAggregator eventAggregator)
|
||||
{
|
||||
_eventAggregator = eventAggregator;
|
||||
}
|
||||
|
||||
public List<Queue> GetQueue()
|
||||
{
|
||||
return _queue;
|
||||
}
|
||||
|
||||
public Queue Find(int id)
|
||||
{
|
||||
return _queue.SingleOrDefault(q => q.Id == id);
|
||||
}
|
||||
|
||||
public void Remove(int id)
|
||||
{
|
||||
_queue.Remove(Find(id));
|
||||
}
|
||||
|
||||
private IEnumerable<Queue> MapQueue(TrackedDownload trackedDownload)
|
||||
{
|
||||
if (trackedDownload.RemoteEpisode?.Episodes != null && trackedDownload.RemoteEpisode.Episodes.Any())
|
||||
{
|
||||
foreach (var episode in trackedDownload.RemoteEpisode.Episodes)
|
||||
{
|
||||
yield return MapQueueItem(trackedDownload, episode);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return MapQueueItem(trackedDownload, null);
|
||||
}
|
||||
}
|
||||
|
||||
private Queue MapQueueItem(TrackedDownload trackedDownload, Episode episode)
|
||||
{
|
||||
var queue = new Queue
|
||||
{
|
||||
Series = trackedDownload.RemoteEpisode?.Series,
|
||||
Episode = episode,
|
||||
Languages = trackedDownload.RemoteEpisode?.Languages ?? new List<Language> { Language.Unknown },
|
||||
Quality = trackedDownload.RemoteEpisode?.ParsedEpisodeInfo.Quality ?? new QualityModel(Quality.Unknown),
|
||||
Title = Parser.Parser.RemoveFileExtension(trackedDownload.DownloadItem.Title),
|
||||
Size = trackedDownload.DownloadItem.TotalSize,
|
||||
SizeLeft = trackedDownload.DownloadItem.RemainingSize,
|
||||
TimeLeft = trackedDownload.DownloadItem.RemainingTime,
|
||||
Status = Enum.TryParse(trackedDownload.DownloadItem.Status.ToString(), out QueueStatus outValue) ? outValue : QueueStatus.Unknown,
|
||||
TrackedDownloadStatus = trackedDownload.Status,
|
||||
TrackedDownloadState = trackedDownload.State,
|
||||
StatusMessages = trackedDownload.StatusMessages.ToList(),
|
||||
ErrorMessage = trackedDownload.DownloadItem.Message,
|
||||
RemoteEpisode = trackedDownload.RemoteEpisode,
|
||||
DownloadId = trackedDownload.DownloadItem.DownloadId,
|
||||
Protocol = trackedDownload.Protocol,
|
||||
DownloadClient = trackedDownload.DownloadItem.DownloadClientInfo.Name,
|
||||
Indexer = trackedDownload.Indexer,
|
||||
OutputPath = trackedDownload.DownloadItem.OutputPath.ToString(),
|
||||
Added = trackedDownload.Added,
|
||||
DownloadClientHasPostImportCategory = trackedDownload.DownloadItem.DownloadClientInfo.HasPostImportCategory
|
||||
};
|
||||
|
||||
queue.Id = HashConverter.GetHashInt31($"trackedDownload-{trackedDownload.DownloadClient}-{trackedDownload.DownloadItem.DownloadId}-ep{episode?.Id ?? 0}");
|
||||
|
||||
if (queue.TimeLeft.HasValue)
|
||||
{
|
||||
queue.EstimatedCompletionTime = DateTime.UtcNow.Add(queue.TimeLeft.Value);
|
||||
}
|
||||
|
||||
return queue;
|
||||
}
|
||||
|
||||
public void Handle(TrackedDownloadRefreshedEvent message)
|
||||
{
|
||||
_queue = message.TrackedDownloads
|
||||
.Where(t => t.IsTrackable)
|
||||
.OrderBy(c => c.DownloadItem.RemainingTime)
|
||||
.SelectMany(MapQueue)
|
||||
.ToList();
|
||||
|
||||
_eventAggregator.PublishEvent(new ObsoleteQueueUpdatedEvent());
|
||||
}
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0612
|
||||
@@ -1,8 +0,0 @@
|
||||
using NzbDrone.Common.Messaging;
|
||||
|
||||
namespace NzbDrone.Core.Queue
|
||||
{
|
||||
public class ObsoleteQueueUpdatedEvent : IEvent
|
||||
{
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user