1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-18 21:35:27 -04:00

Compare commits

...

19 Commits

Author SHA1 Message Date
Mark McDowall 5f359e975d Use react-query for queue UI
New: Season packs and multi-episode releases will show as a single item in the queue
Closes #6537
2025-08-30 14:09:00 -07:00
Mark McDowall e213f156af Add v5 queue endpoints 2025-08-30 14:09:00 -07:00
Mark McDowall 9ebe043bd9 New: Move auth success logging to debug
Closes #7978
2025-08-10 21:26:53 -07:00
Mark McDowall f055e8a3e5 Fixed: Parsing English as the second language in a release name
Closes #8006
2025-08-10 21:26:39 -07:00
Sonarr 8c697afa67 Automated API Docs update
ignore-downstream
2025-08-10 21:20:03 -07:00
Weblate 8d68879edd Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: ArLab1 <arnaud.laberge@hotmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translation: Servarr/Sonarr
2025-08-10 21:19:54 -07:00
Trey Turner e9c82078da Fixed: File air date being updated every refresh
Closes #7989
2025-08-10 21:18:34 -07:00
Stevie Robinson f0798550af New Include Finale type in Webhook and Custom Script connections
Closes #7999
2025-08-10 21:10:48 -07:00
Mark McDowall d9c7838329 Fixed: Sub group parsing could result in extra brackets being parsed
Closes #7994
2025-08-10 21:10:11 -07:00
Mark McDowall b00229e53c Fixed: Treat TaoE and QxR as release group instead of encoder
Closes #7972
2025-08-10 21:10:11 -07:00
Luigi 880628fb68 New: Select with poster click in series selection 2025-08-10 21:09:50 -07:00
Stevie Robinson b09c6f0811 New: Include Mal and AniList IDs in API response and Webhooks
Closes #7973
2025-08-10 21:08:25 -07:00
Mark McDowall b376b63c9e New: Parse '(JA)' as Japanese
Closes #7956
2025-08-10 21:07:32 -07:00
bparkin1283 99feaa34d2 Replace service --status-all with systemctl is-active 2025-08-10 21:07:27 -07:00
Stevie Robinson d7f82a72c2 Fixed: Update nzb.su domain to nzb.life 2025-08-10 21:06:41 -07:00
Stevie Robinson bd20ebfad7 New: Indexer option for Season Pack Seed Ratio 2025-08-10 21:06:20 -07:00
jutoft 71553ad67b New: Tribler 8 download client
Closes #1813
2025-08-10 21:05:40 -07:00
Weblate 41c39f1f28 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: ArLab1 <arnaud.laberge@hotmail.com>
Co-authored-by: Dino <me@dinodev.org>
Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Hugoren Martinako <aumpfbahn@gmail.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: mrchonks <chonkstv@gmail.com>
Co-authored-by: myrad2267 <myrad2267@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/sv/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2025-08-10 21:04:57 -07:00
Mark McDowall d0066358eb Upgraded SixLabors.ImageSharp to 3.1.11 2025-08-01 09:31:00 -07:00
137 changed files with 5274 additions and 2258 deletions
+5 -5
View File
@@ -7,6 +7,7 @@
### Version V1.0.2 2024-01-03 - markus101 - Get user input from /dev/tty ### Version V1.0.2 2024-01-03 - markus101 - Get user input from /dev/tty
### Version V1.0.3 2024-01-06 - StevieTV - exit script when it is ran from install directory ### Version V1.0.3 2024-01-06 - StevieTV - exit script when it is ran from install directory
### Version V1.0.4 2025-04-05 - kaecyra - Allow user/group to be supplied via CLI, add unattended mode ### Version V1.0.4 2025-04-05 - kaecyra - Allow user/group to be supplied via CLI, add unattended mode
### Version V1.0.5 2025-07-08 - bparkin1283 - use systemctl instead of service for stopping app
### Boilerplate Warning ### Boilerplate Warning
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, #THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
@@ -167,11 +168,10 @@ if ! getent group "$app_guid" | grep -qw "$app_uid"; then
echo "Added User [$app_uid] to Group [$app_guid]" echo "Added User [$app_uid] to Group [$app_guid]"
fi fi
# Stop the App if running # Stop and disable the App if running
if service --status-all | grep -Fq "$app"; then if [ $(systemctl is-active "$app") = "active" ]; then
systemctl stop "$app" systemctl disable --now -q "$app"
systemctl disable "$app".service echo "Stopped and disabled existing $app"
echo "Stopped existing $app"
fi fi
# Create Appdata Directory # Create Appdata Directory
@@ -0,0 +1,113 @@
import React, { createContext, ReactNode, useContext, useMemo } from 'react';
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import Queue from 'typings/Queue';
interface EpisodeDetails {
episodeIds: number[];
}
interface SeriesDetails {
seriesId: number;
}
interface AllDetails {
all: boolean;
}
type QueueDetailsFilter = AllDetails | EpisodeDetails | SeriesDetails;
interface QueueDetailsProps {
children: ReactNode;
}
const QueueDetailsContext = createContext<Queue[] | undefined>(undefined);
export default function QueueDetailsProvider({
children,
...filter
}: QueueDetailsProps & QueueDetailsFilter) {
const { data } = useApiQuery<Queue[]>({
path: '/queue/details',
queryParams: { ...filter },
queryOptions: {
enabled: Object.keys(filter).length > 0,
},
});
return (
<QueueDetailsContext.Provider value={data}>
{children}
</QueueDetailsContext.Provider>
);
}
export function useQueueItemForEpisode(episodeId: number) {
const queue = useContext(QueueDetailsContext);
return useMemo(() => {
return queue?.find((item) => item.episodeIds.includes(episodeId));
}, [episodeId, queue]);
}
export function useIsDownloadingEpisodes(episodeIds: number[]) {
const queue = useContext(QueueDetailsContext);
return useMemo(() => {
if (!queue) {
return false;
}
return queue.some((item) =>
item.episodeIds?.some((e) => episodeIds.includes(e))
);
}, [episodeIds, queue]);
}
export interface SeriesQueueDetails {
count: number;
episodesWithFiles: number;
}
export function useQueueDetailsForSeries(
seriesId: number,
seasonNumber?: number
) {
const queue = useContext(QueueDetailsContext);
return useMemo<SeriesQueueDetails>(() => {
if (!queue) {
return { count: 0, episodesWithFiles: 0 };
}
return queue.reduce<SeriesQueueDetails>(
(acc: SeriesQueueDetails, item) => {
if (
item.trackedDownloadState === 'imported' ||
item.seriesId !== seriesId
) {
return acc;
}
if (seasonNumber != null && item.seasonNumber !== seasonNumber) {
return acc;
}
acc.count++;
if (item.episodeHasFile) {
acc.episodesWithFiles++;
}
return acc;
},
{
count: 0,
episodesWithFiles: 0,
}
);
}, [seriesId, seasonNumber, queue]);
}
export const useQueueDetails = () => {
return useContext(QueueDetailsContext) ?? [];
};
@@ -0,0 +1,76 @@
import React from 'react';
import Episode from 'Episode/Episode';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import Series from 'Series/Series';
import translate from 'Utilities/String/translate';
interface EpisodeCellContentProps {
episodes: Episode[];
isFullSeason: boolean;
seasonNumber?: number;
series?: Series;
}
export default function EpisodeCellContent({
episodes,
isFullSeason,
seasonNumber,
series,
}: EpisodeCellContentProps) {
if (episodes.length === 0) {
return '-';
}
if (isFullSeason && seasonNumber != null) {
return translate('SeasonNumberToken', { seasonNumber });
}
if (episodes.length === 1) {
const episode = episodes[0];
return (
<SeasonEpisodeNumber
seasonNumber={episode.seasonNumber}
episodeNumber={episode.episodeNumber}
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
seriesType={series?.seriesType}
alternateTitles={series?.alternateTitles}
sceneSeasonNumber={episode.sceneSeasonNumber}
sceneEpisodeNumber={episode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={episode.sceneAbsoluteEpisodeNumber}
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
/>
);
}
const firstEpisode = episodes[0];
const lastEpisode = episodes[episodes.length - 1];
return (
<>
<SeasonEpisodeNumber
seasonNumber={firstEpisode.seasonNumber}
episodeNumber={firstEpisode.episodeNumber}
absoluteEpisodeNumber={firstEpisode.absoluteEpisodeNumber}
seriesType={series?.seriesType}
alternateTitles={series?.alternateTitles}
sceneSeasonNumber={firstEpisode.sceneSeasonNumber}
sceneEpisodeNumber={firstEpisode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={firstEpisode.sceneAbsoluteEpisodeNumber}
unverifiedSceneNumbering={firstEpisode.unverifiedSceneNumbering}
/>
{' - '}
<SeasonEpisodeNumber
seasonNumber={lastEpisode.seasonNumber}
episodeNumber={lastEpisode.episodeNumber}
absoluteEpisodeNumber={lastEpisode.absoluteEpisodeNumber}
seriesType={series?.seriesType}
alternateTitles={series?.alternateTitles}
sceneSeasonNumber={lastEpisode.sceneSeasonNumber}
sceneEpisodeNumber={lastEpisode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={lastEpisode.sceneAbsoluteEpisodeNumber}
unverifiedSceneNumbering={lastEpisode.unverifiedSceneNumbering}
/>
</>
);
}
@@ -0,0 +1,13 @@
.multiple {
cursor: default;
}
.row {
display: flex;
}
.episodeNumber {
margin-right: 8px;
font-weight: bold;
cursor: default;
}
@@ -0,0 +1,9 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'episodeNumber': string;
'multiple': string;
'row': string;
}
export const cssExports: CssExports;
export default cssExports;
@@ -0,0 +1,66 @@
import React from 'react';
import Popover from 'Components/Tooltip/Popover';
import Episode from 'Episode/Episode';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
import Series from 'Series/Series';
import translate from 'Utilities/String/translate';
import styles from './EpisodeTitleCellContent.css';
interface EpisodeTitleCellContentProps {
episodes: Episode[];
series?: Series;
}
export default function EpisodeTitleCellContent({
episodes,
series,
}: EpisodeTitleCellContentProps) {
if (episodes.length === 0 || !series) {
return '-';
}
if (episodes.length === 1) {
const episode = episodes[0];
return (
<EpisodeTitleLink
episodeId={episode.id}
seriesId={series.id}
episodeTitle={episode.title}
episodeEntity="episodes"
showOpenSeriesButton={true}
/>
);
}
return (
<Popover
anchor={
<span className={styles.multiple}>{translate('MultipleEpisodes')}</span>
}
title={translate('EpisodeTitles')}
body={
<>
{episodes.map((episode) => {
return (
<div key={episode.id} className={styles.row}>
<div className={styles.episodeNumber}>
{episode.episodeNumber}
</div>
<EpisodeTitleLink
episodeId={episode.id}
seriesId={series.id}
episodeTitle={episode.title}
episodeEntity="episodes"
showOpenSeriesButton={true}
/>
</div>
);
})}
</>
}
position="right"
/>
);
}
+76 -105
View File
@@ -7,7 +7,6 @@ import React, {
useState, useState,
} from 'react'; } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames'; import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@@ -22,28 +21,15 @@ import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody'; import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager'; import TablePager from 'Components/Table/TablePager';
import usePaging from 'Components/Table/usePaging';
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector'; import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
import useSelectState from 'Helpers/Hooks/useSelectState'; import useSelectState from 'Helpers/Hooks/useSelectState';
import { align, icons, kinds } from 'Helpers/Props'; import { align, icons, kinds } from 'Helpers/Props';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions'; import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
import {
clearQueue,
fetchQueue,
gotoQueuePage,
grabQueueItems,
removeQueueItems,
setQueueFilter,
setQueueSort,
setQueueTableOption,
} from 'Store/Actions/queueActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import { CheckInputChanged } from 'typings/inputs'; import { CheckInputChanged } from 'typings/inputs';
import { SelectStateInputProps } from 'typings/props'; import { SelectStateInputProps } from 'typings/props';
import QueueItem from 'typings/Queue';
import { TableOptionsChangePayload } from 'typings/Table'; import { TableOptionsChangePayload } from 'typings/Table';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import { import {
@@ -54,33 +40,51 @@ import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds'; import getSelectedIds from 'Utilities/Table/getSelectedIds';
import QueueFilterModal from './QueueFilterModal'; import QueueFilterModal from './QueueFilterModal';
import QueueOptions from './QueueOptions'; import QueueOptions from './QueueOptions';
import {
setQueueOption,
setQueueOptions,
useQueueOptions,
} from './queueOptionsStore';
import QueueRow from './QueueRow'; import QueueRow from './QueueRow';
import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal'; import RemoveQueueItemModal from './RemoveQueueItemModal';
import createQueueStatusSelector from './Status/createQueueStatusSelector'; import useQueueStatus from './Status/useQueueStatus';
import useQueue, {
useFilters,
useGrabQueueItems,
useRemoveQueueItems,
} from './useQueue';
const DEFAULT_DATA = {
records: [],
totalPages: 0,
totalRecords: 0,
};
function Queue() { function Queue() {
const requestCurrentPage = useCurrentPage();
const dispatch = useDispatch(); const dispatch = useDispatch();
const { const {
isFetching, data,
isPopulated,
error, error,
items, isFetching,
columns, isFetched,
selectedFilterKey, isLoading,
filters,
sortKey,
sortDirection,
page, page,
pageSize, goToPage,
totalPages, refetch,
totalRecords, } = useQueue();
isGrabbing,
isRemoving,
} = useSelector((state: AppState) => state.queue.paged);
const { count } = useSelector(createQueueStatusSelector()); 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 { isEpisodesFetching, isEpisodesPopulated, episodesError } = const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
useSelector(createEpisodesFetchingSelector()); useSelector(createEpisodesFetchingSelector());
const customFilters = useSelector(createCustomFiltersSelector('queue')); const customFilters = useSelector(createCustomFiltersSelector('queue'));
@@ -100,41 +104,46 @@ function Queue() {
}, [selectedState]); }, [selectedState]);
const isPendingSelected = useMemo(() => { const isPendingSelected = useMemo(() => {
return items.some((item) => { return records.some((item) => {
return selectedIds.indexOf(item.id) > -1 && item.status === 'delay'; return selectedIds.indexOf(item.id) > -1 && item.status === 'delay';
}); });
}, [items, selectedIds]); }, [records, selectedIds]);
const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] = const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] =
useState(false); useState(false);
const isRefreshing = const isRefreshing =
isFetching || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting; isLoading || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
const isAllPopulated = const isAllPopulated =
isPopulated && isFetched &&
(isEpisodesPopulated || !items.length || items.every((e) => !e.episodeId)); (isEpisodesPopulated ||
!records.length ||
records.every((e) => !e.episodeIds?.length));
const hasError = error || episodesError; const hasError = error || episodesError;
const selectedCount = selectedIds.length; const selectedCount = selectedIds.length;
const disableSelectedActions = selectedCount === 0; const disableSelectedActions = selectedCount === 0;
const handleSelectAllChange = useCallback( const handleSelectAllChange = useCallback(
({ value }: CheckInputChanged) => { ({ value }: CheckInputChanged) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); setSelectState({
type: value ? 'selectAll' : 'unselectAll',
items: records,
});
}, },
[items, setSelectState] [records, setSelectState]
); );
const handleSelectedChange = useCallback( const handleSelectedChange = useCallback(
({ id, value, shiftKey = false }: SelectStateInputProps) => { ({ id, value, shiftKey = false }: SelectStateInputProps) => {
setSelectState({ setSelectState({
type: 'toggleSelected', type: 'toggleSelected',
items, items: records,
id, id,
isSelected: value, isSelected: value,
shiftKey, shiftKey,
}); });
}, },
[items, setSelectState] [records, setSelectState]
); );
const handleRefreshPress = useCallback(() => { const handleRefreshPress = useCallback(() => {
@@ -150,93 +159,60 @@ function Queue() {
}, []); }, []);
const handleGrabSelectedPress = useCallback(() => { const handleGrabSelectedPress = useCallback(() => {
dispatch(grabQueueItems({ ids: selectedIds })); grabQueueItems({ ids: selectedIds });
}, [selectedIds, dispatch]); }, [selectedIds, grabQueueItems]);
const handleRemoveSelectedPress = useCallback(() => { const handleRemoveSelectedPress = useCallback(() => {
shouldBlockRefresh.current = true; shouldBlockRefresh.current = true;
setIsConfirmRemoveModalOpen(true); setIsConfirmRemoveModalOpen(true);
}, [setIsConfirmRemoveModalOpen]); }, [setIsConfirmRemoveModalOpen]);
const handleRemoveSelectedConfirmed = useCallback( const handleRemoveSelectedConfirmed = useCallback(() => {
(payload: RemovePressProps) => { shouldBlockRefresh.current = false;
shouldBlockRefresh.current = false; removeQueueItems({ ids: selectedIds });
dispatch(removeQueueItems({ ids: selectedIds, ...payload })); setIsConfirmRemoveModalOpen(false);
setIsConfirmRemoveModalOpen(false); }, [selectedIds, setIsConfirmRemoveModalOpen, removeQueueItems]);
},
[selectedIds, setIsConfirmRemoveModalOpen, dispatch]
);
const handleConfirmRemoveModalClose = useCallback(() => { const handleConfirmRemoveModalClose = useCallback(() => {
shouldBlockRefresh.current = false; shouldBlockRefresh.current = false;
setIsConfirmRemoveModalOpen(false); setIsConfirmRemoveModalOpen(false);
}, [setIsConfirmRemoveModalOpen]); }, [setIsConfirmRemoveModalOpen]);
const {
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
} = usePaging({
page,
totalPages,
gotoPage: gotoQueuePage,
});
const handleFilterSelect = useCallback( const handleFilterSelect = useCallback(
(selectedFilterKey: string | number) => { (selectedFilterKey: string | number) => {
dispatch(setQueueFilter({ selectedFilterKey })); setQueueOption('selectedFilterKey', selectedFilterKey);
}, },
[dispatch] []
); );
const handleSortPress = useCallback( const handleSortPress = useCallback((sortKey: string) => {
(sortKey: string) => { setQueueOption('sortKey', sortKey);
dispatch(setQueueSort({ sortKey })); }, []);
},
[dispatch]
);
const handleTableOptionChange = useCallback( const handleTableOptionChange = useCallback(
(payload: TableOptionsChangePayload) => { (payload: TableOptionsChangePayload) => {
dispatch(setQueueTableOption(payload)); setQueueOptions(payload);
if (payload.pageSize) { if (payload.pageSize) {
dispatch(gotoQueuePage({ page: 1 })); goToPage(1);
} }
}, },
[dispatch] [goToPage]
); );
useEffect(() => { useEffect(() => {
if (requestCurrentPage) { const episodeIds = selectUniqueIds(records, 'episodeIds');
dispatch(fetchQueue());
} else {
dispatch(gotoQueuePage({ page: 1 }));
}
return () => {
dispatch(clearQueue());
};
}, [requestCurrentPage, dispatch]);
useEffect(() => {
const episodeIds = selectUniqueIds<QueueItem, number | undefined>(
items,
'episodeId'
);
if (episodeIds.length) { if (episodeIds.length) {
dispatch(fetchEpisodes({ episodeIds })); dispatch(fetchEpisodes({ episodeIds }));
} else { } else {
dispatch(clearEpisodes()); dispatch(clearEpisodes());
} }
}, [items, dispatch]); }, [records, dispatch]);
useEffect(() => { useEffect(() => {
const repopulate = () => { const repopulate = () => {
dispatch(fetchQueue()); refetch();
}; };
registerPagePopulator(repopulate); registerPagePopulator(repopulate);
@@ -244,7 +220,7 @@ function Queue() {
return () => { return () => {
unregisterPagePopulator(repopulate); unregisterPagePopulator(repopulate);
}; };
}, [dispatch]); }, [refetch]);
if (!shouldBlockRefresh.current) { if (!shouldBlockRefresh.current) {
currentQueue.current = ( currentQueue.current = (
@@ -255,7 +231,7 @@ function Queue() {
<Alert kind={kinds.DANGER}>{translate('QueueLoadError')}</Alert> <Alert kind={kinds.DANGER}>{translate('QueueLoadError')}</Alert>
) : null} ) : null}
{isAllPopulated && !hasError && !items.length ? ( {isAllPopulated && !hasError && !records.length ? (
<Alert kind={kinds.INFO}> <Alert kind={kinds.INFO}>
{selectedFilterKey !== 'all' && count > 0 {selectedFilterKey !== 'all' && count > 0
? translate('QueueFilterHasNoItems') ? translate('QueueFilterHasNoItems')
@@ -263,7 +239,7 @@ function Queue() {
</Alert> </Alert>
) : null} ) : null}
{isAllPopulated && !hasError && !!items.length ? ( {isAllPopulated && !hasError && !!records.length ? (
<div> <div>
<Table <Table
selectAll={true} selectAll={true}
@@ -279,11 +255,10 @@ function Queue() {
onSortPress={handleSortPress} onSortPress={handleSortPress}
> >
<TableBody> <TableBody>
{items.map((item) => { {records.map((item) => {
return ( return (
<QueueRow <QueueRow
key={item.id} key={item.id}
episodeId={item.episodeId}
isSelected={selectedState[item.id]} isSelected={selectedState[item.id]}
columns={columns} columns={columns}
{...item} {...item}
@@ -302,11 +277,7 @@ function Queue() {
totalPages={totalPages} totalPages={totalPages}
totalRecords={totalRecords} totalRecords={totalRecords}
isFetching={isFetching} isFetching={isFetching}
onFirstPagePress={handleFirstPagePress} onPageSelect={goToPage}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
/> />
</div> </div>
) : null} ) : null}
@@ -377,7 +348,7 @@ function Queue() {
canChangeCategory={ canChangeCategory={
isConfirmRemoveModalOpen && isConfirmRemoveModalOpen &&
selectedIds.every((id) => { selectedIds.every((id) => {
const item = items.find((i) => i.id === id); const item = records.find((i) => i.id === id);
return !!(item && item.downloadClientHasPostImportCategory); return !!(item && item.downloadClientHasPostImportCategory);
}) })
@@ -385,7 +356,7 @@ function Queue() {
canIgnore={ canIgnore={
isConfirmRemoveModalOpen && isConfirmRemoveModalOpen &&
selectedIds.every((id) => { selectedIds.every((id) => {
const item = items.find((i) => i.id === id); const item = records.find((i) => i.id === id);
return !!(item && item.seriesId && item.episodeId); return !!(item && item.seriesId && item.episodeId);
}) })
@@ -393,7 +364,7 @@ function Queue() {
isPending={ isPending={
isConfirmRemoveModalOpen && isConfirmRemoveModalOpen &&
selectedIds.every((id) => { selectedIds.every((id) => {
const item = items.find((i) => i.id === id); const item = records.find((i) => i.id === id);
if (!item) { if (!item) {
return false; return false;
+3 -3
View File
@@ -14,7 +14,7 @@ import styles from './QueueDetails.css';
interface QueueDetailsProps { interface QueueDetailsProps {
title: string; title: string;
size: number; size: number;
sizeleft: number; sizeLeft: number;
estimatedCompletionTime?: string; estimatedCompletionTime?: string;
status: string; status: string;
trackedDownloadState?: QueueTrackedDownloadState; trackedDownloadState?: QueueTrackedDownloadState;
@@ -28,7 +28,7 @@ function QueueDetails(props: QueueDetailsProps) {
const { const {
title, title,
size, size,
sizeleft, sizeLeft,
status, status,
trackedDownloadState = 'downloading', trackedDownloadState = 'downloading',
trackedDownloadStatus = 'ok', trackedDownloadStatus = 'ok',
@@ -37,7 +37,7 @@ function QueueDetails(props: QueueDetailsProps) {
progressBar, progressBar,
} = props; } = props;
const progress = 100 - (sizeleft / size) * 100; const progress = 100 - (sizeLeft / size) * 100;
const isDownloading = status === 'downloading'; const isDownloading = status === 'downloading';
const isPaused = status === 'paused'; const isPaused = status === 'paused';
const hasWarning = trackedDownloadStatus === 'warning'; const hasWarning = trackedDownloadStatus === 'warning';
@@ -1,49 +1,26 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal'; import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
import { setQueueFilter } from 'Store/Actions/queueActions'; import { setQueueOption } from './queueOptionsStore';
import useQueue, { FILTER_BUILDER } from './useQueue';
function createQueueSelector() {
return createSelector(
(state: AppState) => state.queue.paged.items,
(queueItems) => {
return queueItems;
}
);
}
function createFilterBuilderPropsSelector() {
return createSelector(
(state: AppState) => state.queue.paged.filterBuilderProps,
(filterBuilderProps) => {
return filterBuilderProps;
}
);
}
type QueueFilterModalProps = FilterModalProps<History>; type QueueFilterModalProps = FilterModalProps<History>;
export default function QueueFilterModal(props: QueueFilterModalProps) { export default function QueueFilterModal(props: QueueFilterModalProps) {
const sectionItems = useSelector(createQueueSelector()); const { data } = useQueue();
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
const customFilterType = 'queue'; const customFilterType = 'queue';
const dispatch = useDispatch();
const dispatchSetFilter = useCallback( const dispatchSetFilter = useCallback(
(payload: unknown) => { ({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
dispatch(setQueueFilter(payload)); setQueueOption('selectedFilterKey', selectedFilterKey);
}, },
[dispatch] []
); );
return ( return (
<FilterModal <FilterModal
{...props} {...props}
sectionItems={sectionItems} sectionItems={data?.records ?? []}
filterBuilderProps={filterBuilderProps} filterBuilderProps={FILTER_BUILDER}
customFilterType={customFilterType} customFilterType={customFilterType}
dispatchSetFilter={dispatchSetFilter} dispatchSetFilter={dispatchSetFilter}
/> />
+14 -16
View File
@@ -1,33 +1,30 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import FormGroup from 'Components/Form/FormGroup'; import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup'; import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel'; import FormLabel from 'Components/Form/FormLabel';
import { OptionChanged } from 'Helpers/Hooks/useOptionsStore';
import { inputTypes } from 'Helpers/Props'; import { inputTypes } from 'Helpers/Props';
import { gotoQueuePage, setQueueOption } from 'Store/Actions/queueActions';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import {
QueueOptions as QueueOptionsType,
setQueueOption,
useQueueOption,
} from './queueOptionsStore';
import useQueue from './useQueue';
function QueueOptions() { function QueueOptions() {
const dispatch = useDispatch(); const includeUnknownSeriesItems = useQueueOption('includeUnknownSeriesItems');
const { includeUnknownSeriesItems } = useSelector( const { goToPage } = useQueue();
(state: AppState) => state.queue.options
);
const handleOptionChange = useCallback( const handleOptionChange = useCallback(
({ name, value }: InputChanged<boolean>) => { ({ name, value }: OptionChanged<QueueOptionsType>) => {
dispatch( setQueueOption(name, value);
setQueueOption({
[name]: value,
})
);
if (name === 'includeUnknownSeriesItems') { if (name === 'includeUnknownSeriesItems') {
dispatch(gotoQueuePage({ page: 1 })); goToPage(1);
} }
}, },
[dispatch] [goToPage]
); );
return ( return (
@@ -39,6 +36,7 @@ function QueueOptions() {
name="includeUnknownSeriesItems" name="includeUnknownSeriesItems"
value={includeUnknownSeriesItems} value={includeUnknownSeriesItems}
helpText={translate('ShowUnknownSeriesItemsHelpText')} helpText={translate('ShowUnknownSeriesItemsHelpText')}
// @ts-expect-error - The typing for inputs needs more work
onChange={handleOptionChange} onChange={handleOptionChange}
/> />
</FormGroup> </FormGroup>
+65 -66
View File
@@ -1,7 +1,6 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
import { Error } from 'App/State/AppSectionState';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import ProgressBar from 'Components/ProgressBar'; import ProgressBar from 'Components/ProgressBar';
@@ -15,16 +14,13 @@ import DownloadProtocol from 'DownloadClient/DownloadProtocol';
import EpisodeFormats from 'Episode/EpisodeFormats'; import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages'; import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality'; import EpisodeQuality from 'Episode/EpisodeQuality';
import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; import useEpisodes from 'Episode/useEpisodes';
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import useEpisode from 'Episode/useEpisode';
import { icons, kinds, tooltipPositions } from 'Helpers/Props'; import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import Language from 'Language/Language'; import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality'; import { QualityModel } from 'Quality/Quality';
import SeriesTitleLink from 'Series/SeriesTitleLink'; import SeriesTitleLink from 'Series/SeriesTitleLink';
import useSeries from 'Series/useSeries'; import useSeries from 'Series/useSeries';
import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import CustomFormat from 'typings/CustomFormat'; import CustomFormat from 'typings/CustomFormat';
import { SelectStateInputProps } from 'typings/props'; import { SelectStateInputProps } from 'typings/props';
@@ -36,15 +32,18 @@ import {
import formatBytes from 'Utilities/Number/formatBytes'; import formatBytes from 'Utilities/Number/formatBytes';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import EpisodeCellContent from './EpisodeCellContent';
import EpisodeTitleCellContent from './EpisodeTitleCellContent';
import QueueStatusCell from './QueueStatusCell'; import QueueStatusCell from './QueueStatusCell';
import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal'; import RemoveQueueItemModal from './RemoveQueueItemModal';
import TimeleftCell from './TimeleftCell'; import TimeLeftCell from './TimeLeftCell';
import { useGrabQueueItem, useRemoveQueueItem } from './useQueue';
import styles from './QueueRow.css'; import styles from './QueueRow.css';
interface QueueRowProps { interface QueueRowProps {
id: number; id: number;
seriesId?: number; seriesId?: number;
episodeId?: number; episodeIds: number[];
downloadId?: string; downloadId?: string;
title: string; title: string;
status: string; status: string;
@@ -58,16 +57,16 @@ interface QueueRowProps {
customFormatScore: number; customFormatScore: number;
protocol: DownloadProtocol; protocol: DownloadProtocol;
indexer?: string; indexer?: string;
isFullSeason: boolean;
seasonNumbers: number[];
outputPath?: string; outputPath?: string;
downloadClient?: string; downloadClient?: string;
downloadClientHasPostImportCategory?: boolean; downloadClientHasPostImportCategory?: boolean;
estimatedCompletionTime?: string; estimatedCompletionTime?: string;
added?: string; added?: string;
timeleft?: string; timeLeft?: string;
size: number; size: number;
sizeleft: number; sizeLeft: number;
isGrabbing?: boolean;
grabError?: Error;
isRemoving?: boolean; isRemoving?: boolean;
isSelected?: boolean; isSelected?: boolean;
columns: Column[]; columns: Column[];
@@ -79,7 +78,7 @@ function QueueRow(props: QueueRowProps) {
const { const {
id, id,
seriesId, seriesId,
episodeId, episodeIds,
downloadId, downloadId,
title, title,
status, status,
@@ -97,25 +96,25 @@ function QueueRow(props: QueueRowProps) {
downloadClient, downloadClient,
downloadClientHasPostImportCategory, downloadClientHasPostImportCategory,
estimatedCompletionTime, estimatedCompletionTime,
isFullSeason,
seasonNumbers,
added, added,
timeleft, timeLeft,
size, size,
sizeleft, sizeLeft,
isGrabbing = false,
grabError,
isRemoving = false,
isSelected, isSelected,
columns, columns,
onSelectedChange, onSelectedChange,
onQueueRowModalOpenOrClose, onQueueRowModalOpenOrClose,
} = props; } = props;
const dispatch = useDispatch();
const series = useSeries(seriesId); const series = useSeries(seriesId);
const episode = useEpisode(episodeId, 'episodes'); const episodes = useEpisodes(episodeIds, 'episodes');
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector( const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
createUISettingsSelector() createUISettingsSelector()
); );
const { removeQueueItem, isRemoving } = useRemoveQueueItem(id);
const { grabQueueItem, isGrabbing, grabError } = useGrabQueueItem(id);
const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] = const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] =
useState(false); useState(false);
@@ -124,8 +123,8 @@ function QueueRow(props: QueueRowProps) {
useState(false); useState(false);
const handleGrabPress = useCallback(() => { const handleGrabPress = useCallback(() => {
dispatch(grabQueueItem({ id })); grabQueueItem();
}, [id, dispatch]); }, [grabQueueItem]);
const handleInteractiveImportPress = useCallback(() => { const handleInteractiveImportPress = useCallback(() => {
onQueueRowModalOpenOrClose(true); onQueueRowModalOpenOrClose(true);
@@ -142,21 +141,22 @@ function QueueRow(props: QueueRowProps) {
setIsRemoveQueueItemModalOpen(true); setIsRemoveQueueItemModalOpen(true);
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]); }, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
const handleRemoveQueueItemModalConfirmed = useCallback( const handleRemoveQueueItemModalConfirmed = useCallback(() => {
(payload: RemovePressProps) => { onQueueRowModalOpenOrClose(false);
onQueueRowModalOpenOrClose(false); removeQueueItem();
dispatch(removeQueueItem({ id, ...payload })); setIsRemoveQueueItemModalOpen(false);
setIsRemoveQueueItemModalOpen(false); }, [
}, setIsRemoveQueueItemModalOpen,
[id, setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose, dispatch] removeQueueItem,
); onQueueRowModalOpenOrClose,
]);
const handleRemoveQueueItemModalClose = useCallback(() => { const handleRemoveQueueItemModalClose = useCallback(() => {
onQueueRowModalOpenOrClose(false); onQueueRowModalOpenOrClose(false);
setIsRemoveQueueItemModalOpen(false); setIsRemoveQueueItemModalOpen(false);
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]); }, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
const progress = 100 - (sizeleft / size) * 100; const progress = 100 - (sizeLeft / size) * 100;
const showInteractiveImport = const showInteractiveImport =
status === 'completed' && trackedDownloadStatus === 'warning'; status === 'completed' && trackedDownloadStatus === 'warning';
const isPending = const isPending =
@@ -209,23 +209,12 @@ function QueueRow(props: QueueRowProps) {
if (name === 'episode') { if (name === 'episode') {
return ( return (
<TableRowCell key={name}> <TableRowCell key={name}>
{episode ? ( <EpisodeCellContent
<SeasonEpisodeNumber episodes={episodes}
seasonNumber={episode.seasonNumber} isFullSeason={isFullSeason}
episodeNumber={episode.episodeNumber} seasonNumber={seasonNumbers[0]}
absoluteEpisodeNumber={episode.absoluteEpisodeNumber} series={series}
seriesType={series?.seriesType} />
alternateTitles={series?.alternateTitles}
sceneSeasonNumber={episode.sceneSeasonNumber}
sceneEpisodeNumber={episode.sceneEpisodeNumber}
sceneAbsoluteEpisodeNumber={
episode.sceneAbsoluteEpisodeNumber
}
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
/>
) : (
'-'
)}
</TableRowCell> </TableRowCell>
); );
} }
@@ -233,27 +222,37 @@ function QueueRow(props: QueueRowProps) {
if (name === 'episodes.title') { if (name === 'episodes.title') {
return ( return (
<TableRowCell key={name}> <TableRowCell key={name}>
{series && episode ? ( <EpisodeTitleCellContent episodes={episodes} series={series} />
<EpisodeTitleLink
episodeId={episode.id}
seriesId={series.id}
episodeTitle={episode.title}
episodeEntity="episodes"
showOpenSeriesButton={true}
/>
) : (
'-'
)}
</TableRowCell> </TableRowCell>
); );
} }
if (name === 'episodes.airDateUtc') { if (name === 'episodes.airDateUtc') {
if (episode) { if (episodes.length === 0) {
return <RelativeDateCell key={name} date={episode.airDateUtc} />; return <TableRowCell key={name}>-</TableRowCell>;
} }
return <TableRowCell key={name}>-</TableRowCell>; if (episodes.length === 1) {
return (
<RelativeDateCell key={name} date={episodes[0].airDateUtc} />
);
}
return (
<TableRowCell key={name}>
<RelativeDateCell
key={name}
component="span"
date={episodes[0].airDateUtc}
/>
{' - '}
<RelativeDateCell
key={name}
component="span"
date={episodes[episodes.length - 1].airDateUtc}
/>
</TableRowCell>
);
} }
if (name === 'languages') { if (name === 'languages') {
@@ -325,13 +324,13 @@ function QueueRow(props: QueueRowProps) {
if (name === 'estimatedCompletionTime') { if (name === 'estimatedCompletionTime') {
return ( return (
<TimeleftCell <TimeLeftCell
key={name} key={name}
status={status} status={status}
estimatedCompletionTime={estimatedCompletionTime} estimatedCompletionTime={estimatedCompletionTime}
timeleft={timeleft} timeLeft={timeLeft}
size={size} size={size}
sizeleft={sizeleft} sizeLeft={sizeLeft}
showRelativeDates={showRelativeDates} showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat} shortDateFormat={shortDateFormat}
timeFormat={timeFormat} timeFormat={timeFormat}
@@ -1,6 +1,4 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import FormGroup from 'Components/Form/FormGroup'; import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup'; import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel'; import FormLabel from 'Components/Form/FormLabel';
@@ -11,19 +9,16 @@ import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent'; import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter'; import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader'; import ModalHeader from 'Components/Modal/ModalHeader';
import { OptionChanged } from 'Helpers/Hooks/useOptionsStore';
import { inputTypes, kinds, sizes } from 'Helpers/Props'; import { inputTypes, kinds, sizes } from 'Helpers/Props';
import { setQueueRemovalOption } from 'Store/Actions/queueActions';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import {
QueueOptions,
setQueueOption,
useQueueOption,
} from './queueOptionsStore';
import styles from './RemoveQueueItemModal.css'; import styles from './RemoveQueueItemModal.css';
export interface RemovePressProps {
remove: boolean;
changeCategory: boolean;
blocklist: boolean;
skipRedownload: boolean;
}
interface RemoveQueueItemModalProps { interface RemoveQueueItemModalProps {
isOpen: boolean; isOpen: boolean;
sourceTitle?: string; sourceTitle?: string;
@@ -31,7 +26,7 @@ interface RemoveQueueItemModalProps {
canIgnore: boolean; canIgnore: boolean;
isPending: boolean; isPending: boolean;
selectedCount?: number; selectedCount?: number;
onRemovePress(props: RemovePressProps): void; onRemovePress(): void;
onModalClose: () => void; onModalClose: () => void;
} }
@@ -47,13 +42,8 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
onModalClose, onModalClose,
} = props; } = props;
const dispatch = useDispatch();
const multipleSelected = selectedCount && selectedCount > 1; const multipleSelected = selectedCount && selectedCount > 1;
const { removalMethod, blocklistMethod } = useQueueOption('removalOptions');
const { removalMethod, blocklistMethod } = useSelector(
(state: AppState) => state.queue.removalOptions
);
const { title, message } = useMemo(() => { const { title, message } = useMemo(() => {
if (!selectedCount) { if (!selectedCount) {
@@ -138,20 +128,19 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
}, [isPending, multipleSelected]); }, [isPending, multipleSelected]);
const handleRemovalOptionInputChange = useCallback( const handleRemovalOptionInputChange = useCallback(
({ name, value }: InputChanged) => { ({ name, value }: OptionChanged<QueueOptions['removalOptions']>) => {
dispatch(setQueueRemovalOption({ [name]: value })); setQueueOption('removalOptions', {
removalMethod,
blocklistMethod,
[name]: value,
});
}, },
[dispatch] [removalMethod, blocklistMethod]
); );
const handleConfirmRemove = useCallback(() => { const handleConfirmRemove = useCallback(() => {
onRemovePress({ onRemovePress();
remove: removalMethod === 'removeFromClient', }, [onRemovePress]);
changeCategory: removalMethod === 'changeCategory',
blocklist: blocklistMethod !== 'doNotBlocklist',
skipRedownload: blocklistMethod === 'blocklistOnly',
});
}, [removalMethod, blocklistMethod, onRemovePress]);
const handleModalClose = useCallback(() => { const handleModalClose = useCallback(() => {
onModalClose(); onModalClose();
@@ -178,6 +167,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
helpTextWarning={translate( helpTextWarning={translate(
'RemoveQueueItemRemovalMethodHelpTextWarning' 'RemoveQueueItemRemovalMethodHelpTextWarning'
)} )}
// @ts-expect-error - The typing for inputs needs more work
onChange={handleRemovalOptionInputChange} onChange={handleRemovalOptionInputChange}
/> />
</FormGroup> </FormGroup>
@@ -196,6 +186,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
value={blocklistMethod} value={blocklistMethod}
values={blocklistMethodOptions} values={blocklistMethodOptions}
helpText={translate('BlocklistReleaseHelpText')} helpText={translate('BlocklistReleaseHelpText')}
// @ts-expect-error - The typing for inputs needs more work
onChange={handleRemovalOptionInputChange} onChange={handleRemovalOptionInputChange}
/> />
</FormGroup> </FormGroup>
@@ -1,33 +1,9 @@
import React, { useEffect } from 'react'; import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus'; import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
import usePrevious from 'Helpers/Hooks/usePrevious'; import useQueueStatus from './useQueueStatus';
import { fetchQueueStatus } from 'Store/Actions/queueActions';
import createQueueStatusSelector from './createQueueStatusSelector';
function QueueStatus() { function QueueStatus() {
const dispatch = useDispatch(); const { errors, warnings, count } = useQueueStatus();
const { isConnected, isReconnecting } = useSelector(
(state: AppState) => state.app
);
const { isPopulated, count, errors, warnings } = useSelector(
createQueueStatusSelector()
);
const wasReconnecting = usePrevious(isReconnecting);
useEffect(() => {
if (!isPopulated) {
dispatch(fetchQueueStatus());
}
}, [isPopulated, dispatch]);
useEffect(() => {
if (isConnected && wasReconnecting) {
dispatch(fetchQueueStatus());
}
}, [isConnected, wasReconnecting, dispatch]);
return ( return (
<PageSidebarStatus count={count} errors={errors} warnings={warnings} /> <PageSidebarStatus count={count} errors={errors} warnings={warnings} />
@@ -1,32 +0,0 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
function createQueueStatusSelector() {
return createSelector(
(state: AppState) => state.queue.status.isPopulated,
(state: AppState) => state.queue.status.item,
(state: AppState) => state.queue.options.includeUnknownSeriesItems,
(isPopulated, status, includeUnknownSeriesItems) => {
const {
errors,
warnings,
unknownErrors,
unknownWarnings,
count,
totalCount,
} = status;
return {
...status,
isPopulated,
count: includeUnknownSeriesItems ? totalCount : count,
errors: includeUnknownSeriesItems ? errors || unknownErrors : errors,
warnings: includeUnknownSeriesItems
? warnings || unknownWarnings
: warnings,
};
}
);
}
export default createQueueStatusSelector;
@@ -0,0 +1,54 @@
import useApiQuery from 'Helpers/Hooks/useApiQuery';
import { useQueueOption } from '../queueOptionsStore';
export interface QueueStatus {
totalCount: number;
count: number;
unknownCount: number;
errors: boolean;
warnings: boolean;
unknownErrors: boolean;
unknownWarnings: boolean;
}
export default function useQueueStatus() {
const includeUnknownSeriesItems = useQueueOption('includeUnknownSeriesItems');
const { data } = useApiQuery<QueueStatus>({
path: '/queue/status',
queryParams: {
includeUnknownSeriesItems,
},
});
if (!data) {
return {
count: 0,
errors: false,
warnings: false,
};
}
const {
errors,
warnings,
unknownErrors,
unknownWarnings,
count,
totalCount,
} = data;
if (includeUnknownSeriesItems) {
return {
count: totalCount,
errors: errors || unknownErrors,
warnings: warnings || unknownWarnings,
};
}
return {
count,
errors,
warnings,
};
}
@@ -1,4 +1,4 @@
.timeleft { .timeLeft {
composes: cell from '~Components/Table/Cells/TableRowCell.css'; composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 100px; width: 100px;
@@ -1,7 +1,7 @@
// This file is automatically generated. // This file is automatically generated.
// Please do not change this file! // Please do not change this file!
interface CssExports { interface CssExports {
'timeleft': string; 'timeLeft': string;
} }
export const cssExports: CssExports; export const cssExports: CssExports;
export default cssExports; export default cssExports;
@@ -8,26 +8,26 @@ import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import getRelativeDate from 'Utilities/Date/getRelativeDate'; import getRelativeDate from 'Utilities/Date/getRelativeDate';
import formatBytes from 'Utilities/Number/formatBytes'; import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import styles from './TimeleftCell.css'; import styles from './TimeLeftCell.css';
interface TimeleftCellProps { interface TimeLeftCellProps {
estimatedCompletionTime?: string; estimatedCompletionTime?: string;
timeleft?: string; timeLeft?: string;
status: string; status: string;
size: number; size: number;
sizeleft: number; sizeLeft: number;
showRelativeDates: boolean; showRelativeDates: boolean;
shortDateFormat: string; shortDateFormat: string;
timeFormat: string; timeFormat: string;
} }
function TimeleftCell(props: TimeleftCellProps) { function TimeLeftCell(props: TimeLeftCellProps) {
const { const {
estimatedCompletionTime, estimatedCompletionTime,
timeleft, timeLeft,
status, status,
size, size,
sizeleft, sizeLeft,
showRelativeDates, showRelativeDates,
shortDateFormat, shortDateFormat,
timeFormat, timeFormat,
@@ -44,7 +44,7 @@ function TimeleftCell(props: TimeleftCellProps) {
}); });
return ( return (
<TableRowCell className={styles.timeleft}> <TableRowCell className={styles.timeLeft}>
<Tooltip <Tooltip
anchor={<Icon name={icons.INFO} />} anchor={<Icon name={icons.INFO} />}
tooltip={translate('DelayingDownloadUntil', { date, time })} tooltip={translate('DelayingDownloadUntil', { date, time })}
@@ -66,7 +66,7 @@ function TimeleftCell(props: TimeleftCellProps) {
}); });
return ( return (
<TableRowCell className={styles.timeleft}> <TableRowCell className={styles.timeLeft}>
<Tooltip <Tooltip
anchor={<Icon name={icons.INFO} />} anchor={<Icon name={icons.INFO} />}
tooltip={translate('RetryingDownloadOn', { date, time })} tooltip={translate('RetryingDownloadOn', { date, time })}
@@ -77,21 +77,21 @@ function TimeleftCell(props: TimeleftCellProps) {
); );
} }
if (!timeleft || status === 'completed' || status === 'failed') { if (!timeLeft || status === 'completed' || status === 'failed') {
return <TableRowCell className={styles.timeleft}>-</TableRowCell>; return <TableRowCell className={styles.timeLeft}>-</TableRowCell>;
} }
const totalSize = formatBytes(size); const totalSize = formatBytes(size);
const remainingSize = formatBytes(sizeleft); const remainingSize = formatBytes(sizeLeft);
return ( return (
<TableRowCell <TableRowCell
className={styles.timeleft} className={styles.timeLeft}
title={`${remainingSize} / ${totalSize}`} title={`${remainingSize} / ${totalSize}`}
> >
{formatTimeSpan(timeleft)} {formatTimeSpan(timeLeft)}
</TableRowCell> </TableRowCell>
); );
} }
export default TimeleftCell; export default TimeLeftCell;
@@ -0,0 +1,164 @@
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;
+210
View File
@@ -0,0 +1,210 @@
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,11 +47,7 @@ function AddNewSeriesModalContent({
const { isSmallScreen } = useSelector(createDimensionsSelector()); const { isSmallScreen } = useSelector(createDimensionsSelector());
const isWindows = useIsWindows(); const isWindows = useIsWindows();
const { const { isAdding, addError, addSeries } = useAddSeries();
isPending: isAdding,
error: addError,
mutate: addSeries,
} = useAddSeries();
const { settings, validationErrors, validationWarnings } = useMemo(() => { const { settings, validationErrors, validationWarnings } = useMemo(() => {
return selectSettings(options, {}, addError); return selectSettings(options, {}, addError);
@@ -33,11 +33,19 @@ export const useAddSeries = () => {
[dispatch] [dispatch]
); );
return useApiMutation<Series, AddSeriesPayload>({ const { isPending, error, mutate } = useApiMutation<Series, AddSeriesPayload>(
path: '/series', {
method: 'POST', path: '/series',
mutationOptions: { method: 'POST',
onSuccess: onAddSuccess, mutationOptions: {
}, onSuccess: onAddSuccess,
}); },
}
);
return {
isAdding: isPending,
addError: error,
addSeries: mutate,
};
}; };
@@ -1,4 +1,4 @@
import { createPersist } from 'Helpers/createPersist'; import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
import { SeriesMonitor, SeriesType } from 'Series/Series'; import { SeriesMonitor, SeriesType } from 'Series/Series';
export interface AddSeriesOptions { export interface AddSeriesOptions {
@@ -12,9 +12,8 @@ export interface AddSeriesOptions {
tags: number[]; tags: number[];
} }
const addSeriesOptionsStore = createPersist<AddSeriesOptions>( const { useOptions, useOption, setOption } =
'add_series_options', createOptionsStore<AddSeriesOptions>('add_series_options', () => {
() => {
return { return {
rootFolderPath: '', rootFolderPath: '',
monitor: 'all', monitor: 'all',
@@ -25,25 +24,8 @@ const addSeriesOptionsStore = createPersist<AddSeriesOptions>(
searchForCutoffUnmetEpisodes: false, searchForCutoffUnmetEpisodes: false,
tags: [], tags: [],
}; };
} });
);
export const useAddSeriesOptions = () => { export const useAddSeriesOptions = useOptions;
return addSeriesOptionsStore((state) => state); export const useAddSeriesOption = useOption;
}; export const setAddSeriesOption = setOption;
export const useAddSeriesOption = <K extends keyof AddSeriesOptions>(
key: K
) => {
return addSeriesOptionsStore((state) => state[key]);
};
export const setAddSeriesOption = <K extends keyof AddSeriesOptions>(
key: K,
value: AddSeriesOptions[K]
) => {
addSeriesOptionsStore.setState((state) => ({
...state,
[key]: value,
}));
};
-2
View File
@@ -18,7 +18,6 @@ import OrganizePreviewAppState from './OrganizePreviewAppState';
import ParseAppState from './ParseAppState'; import ParseAppState from './ParseAppState';
import PathsAppState from './PathsAppState'; import PathsAppState from './PathsAppState';
import ProviderOptionsAppState from './ProviderOptionsAppState'; import ProviderOptionsAppState from './ProviderOptionsAppState';
import QueueAppState from './QueueAppState';
import ReleasesAppState from './ReleasesAppState'; import ReleasesAppState from './ReleasesAppState';
import RootFolderAppState from './RootFolderAppState'; import RootFolderAppState from './RootFolderAppState';
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState'; import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
@@ -99,7 +98,6 @@ interface AppState {
parse: ParseAppState; parse: ParseAppState;
paths: PathsAppState; paths: PathsAppState;
providerOptions: ProviderOptionsAppState; providerOptions: ProviderOptionsAppState;
queue: QueueAppState;
releases: ReleasesAppState; releases: ReleasesAppState;
rootFolders: RootFolderAppState; rootFolders: RootFolderAppState;
series: SeriesAppState; series: SeriesAppState;
-56
View File
@@ -1,56 +0,0 @@
import Queue from 'typings/Queue';
import AppSectionState, {
AppSectionFilterState,
AppSectionItemState,
Error,
PagedAppSectionState,
TableAppSectionState,
} from './AppSectionState';
export interface QueueStatus {
totalCount: number;
count: number;
unknownCount: number;
errors: boolean;
warnings: boolean;
unknownErrors: boolean;
unknownWarnings: boolean;
}
export interface QueueDetailsAppState extends AppSectionState<Queue> {
params: unknown;
}
export interface QueuePagedAppState
extends AppSectionState<Queue>,
AppSectionFilterState<Queue>,
PagedAppSectionState,
TableAppSectionState {
isGrabbing: boolean;
grabError: Error;
isRemoving: boolean;
removeError: Error;
}
export type RemovalMethod = 'removeFromClient' | 'changeCategory' | 'ignore';
export type BlocklistMethod =
| 'doNotBlocklist'
| 'blocklistAndSearch'
| 'blocklistOnly';
interface RemovalOptions {
removalMethod: RemovalMethod;
blocklistMethod: BlocklistMethod;
}
interface QueueAppState {
status: AppSectionItemState<QueueStatus>;
details: QueueDetailsAppState;
paged: QueuePagedAppState;
options: {
includeUnknownSeriesItems: boolean;
};
removalOptions: RemovalOptions;
}
export default QueueAppState;
+2 -2
View File
@@ -2,6 +2,7 @@ import classNames from 'classnames';
import moment from 'moment'; import moment from 'moment';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState'; import AppState from 'App/State/AppState';
import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails'; import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
import getStatusStyle from 'Calendar/getStatusStyle'; import getStatusStyle from 'Calendar/getStatusStyle';
@@ -13,7 +14,6 @@ import getFinaleTypeName from 'Episode/getFinaleTypeName';
import useEpisodeFile from 'EpisodeFile/useEpisodeFile'; import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds } from 'Helpers/Props'; import { icons, kinds } from 'Helpers/Props';
import useSeries from 'Series/useSeries'; import useSeries from 'Series/useSeries';
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatTime from 'Utilities/Date/formatTime'; import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber'; import padNumber from 'Utilities/Number/padNumber';
@@ -57,7 +57,7 @@ function AgendaEvent(props: AgendaEventProps) {
const series = useSeries(seriesId)!; const series = useSeries(seriesId)!;
const episodeFile = useEpisodeFile(episodeFileId); const episodeFile = useEpisodeFile(episodeFileId);
const queueItem = useSelector(createQueueItemSelectorForHook(id)); const queueItem = useQueueItemForEpisode(id);
const { timeFormat, longDateFormat, enableColorImpairedMode } = useSelector( const { timeFormat, longDateFormat, enableColorImpairedMode } = useSelector(
createUISettingsSelector() createUISettingsSelector()
); );
-14
View File
@@ -17,10 +17,6 @@ import {
clearEpisodeFiles, clearEpisodeFiles,
fetchEpisodeFiles, fetchEpisodeFiles,
} from 'Store/Actions/episodeFileActions'; } from 'Store/Actions/episodeFileActions';
import {
clearQueueDetails,
fetchQueueDetails,
} from 'Store/Actions/queueActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
@@ -74,7 +70,6 @@ function Calendar() {
return () => { return () => {
dispatch(clearCalendar()); dispatch(clearCalendar());
dispatch(clearQueueDetails());
dispatch(clearEpisodeFiles()); dispatch(clearEpisodeFiles());
clearTimeout(updateTimeout.current); clearTimeout(updateTimeout.current);
}; };
@@ -90,7 +85,6 @@ function Calendar() {
useEffect(() => { useEffect(() => {
const repopulate = () => { const repopulate = () => {
dispatch(fetchQueueDetails({ time, view }));
dispatch(fetchCalendar({ time, view })); dispatch(fetchCalendar({ time, view }));
}; };
@@ -125,16 +119,11 @@ function Calendar() {
useEffect(() => { useEffect(() => {
if (!previousItems || hasDifferentItems(items, previousItems)) { if (!previousItems || hasDifferentItems(items, previousItems)) {
const episodeIds = selectUniqueIds<Episode, number>(items, 'id');
const episodeFileIds = selectUniqueIds<Episode, number>( const episodeFileIds = selectUniqueIds<Episode, number>(
items, items,
'episodeFileId' 'episodeFileId'
); );
if (items.length) {
dispatch(fetchQueueDetails({ episodeIds }));
}
if (episodeFileIds.length) { if (episodeFileIds.length) {
dispatch(fetchEpisodeFiles({ episodeFileIds })); dispatch(fetchEpisodeFiles({ episodeFileIds }));
} }
@@ -144,18 +133,15 @@ function Calendar() {
return ( return (
<div className={styles.calendar}> <div className={styles.calendar}>
{isFetching && !isPopulated ? <LoadingIndicator /> : null} {isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isFetching && error ? ( {!isFetching && error ? (
<Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert> <Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert>
) : null} ) : null}
{!error && isPopulated && view === 'agenda' ? ( {!error && isPopulated && view === 'agenda' ? (
<div className={styles.calendarContent}> <div className={styles.calendarContent}>
<CalendarHeader /> <CalendarHeader />
<Agenda /> <Agenda />
</div> </div>
) : null} ) : null}
{!error && isPopulated && view !== 'agenda' ? ( {!error && isPopulated && view !== 'agenda' ? (
<div className={styles.calendarContent}> <div className={styles.calendarContent}>
<CalendarHeader /> <CalendarHeader />
@@ -0,0 +1,78 @@
import moment from 'moment';
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { useQueueDetails } from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import { icons } from 'Helpers/Props';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import Queue from 'typings/Queue';
import { isCommandExecuting } from 'Utilities/Command';
import isBefore from 'Utilities/Date/isBefore';
import translate from 'Utilities/String/translate';
function createIsSearchingSelector() {
return createSelector(
(state: AppState) => state.calendar.searchMissingCommandId,
createCommandsSelector(),
(searchMissingCommandId, commands) => {
if (searchMissingCommandId == null) {
return false;
}
return isCommandExecuting(
commands.find((command) => {
return command.id === searchMissingCommandId;
})
);
}
);
}
function createMissingEpisodeIdsSelector(queueDetails: Queue[]) {
return createSelector(
(state: AppState) => state.calendar.start,
(state: AppState) => state.calendar.end,
(state: AppState) => state.calendar.items,
(start, end, episodes) => {
return episodes.reduce<number[]>((acc, episode) => {
const airDateUtc = episode.airDateUtc;
if (
!episode.episodeFileId &&
moment(airDateUtc).isAfter(start) &&
moment(airDateUtc).isBefore(end) &&
isBefore(episode.airDateUtc) &&
!queueDetails.some(
(details) => !!details.episode && details.episode.id === episode.id
)
) {
acc.push(episode.id);
}
return acc;
}, []);
}
);
}
export default function CalendarMissingEpisodeSearchButton() {
const queueDetails = useQueueDetails();
const missingEpisodeIds = useSelector(
createMissingEpisodeIdsSelector(queueDetails)
);
const isSearchingForMissing = useSelector(createIsSearchingSelector());
const handlePress = useCallback(() => {}, []);
return (
<PageToolbarButton
label={translate('SearchForMissing')}
iconName={icons.SEARCH}
isDisabled={!missingEpisodeIds.length}
isSpinning={isSearchingForMissing}
onPress={handlePress}
/>
);
}
+63 -117
View File
@@ -1,7 +1,6 @@
import moment from 'moment'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect'; import QueueDetails from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState'; import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames'; import * as commandNames from 'Commands/commandNames';
import FilterMenu from 'Components/Menu/FilterMenu'; import FilterMenu from 'Components/Menu/FilterMenu';
@@ -11,24 +10,23 @@ import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import Episode from 'Episode/Episode';
import useMeasure from 'Helpers/Hooks/useMeasure'; import useMeasure from 'Helpers/Hooks/useMeasure';
import { align, icons } from 'Helpers/Props'; import { align, icons } from 'Helpers/Props';
import NoSeries from 'Series/NoSeries'; import NoSeries from 'Series/NoSeries';
import { import {
searchMissing,
setCalendarDaysCount, setCalendarDaysCount,
setCalendarFilter, setCalendarFilter,
} from 'Store/Actions/calendarActions'; } from 'Store/Actions/calendarActions';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector'; import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
import { isCommandExecuting } from 'Utilities/Command'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import isBefore from 'Utilities/Date/isBefore';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import Calendar from './Calendar'; import Calendar from './Calendar';
import CalendarFilterModal from './CalendarFilterModal'; import CalendarFilterModal from './CalendarFilterModal';
import CalendarMissingEpisodeSearchButton from './CalendarMissingEpisodeSearchButton';
import CalendarLinkModal from './iCal/CalendarLinkModal'; import CalendarLinkModal from './iCal/CalendarLinkModal';
import Legend from './Legend/Legend'; import Legend from './Legend/Legend';
import CalendarOptionsModal from './Options/CalendarOptionsModal'; import CalendarOptionsModal from './Options/CalendarOptionsModal';
@@ -36,60 +34,12 @@ import styles from './CalendarPage.css';
const MINIMUM_DAY_WIDTH = 120; const MINIMUM_DAY_WIDTH = 120;
function createMissingEpisodeIdsSelector() {
return createSelector(
(state: AppState) => state.calendar.start,
(state: AppState) => state.calendar.end,
(state: AppState) => state.calendar.items,
(state: AppState) => state.queue.details.items,
(start, end, episodes, queueDetails) => {
return episodes.reduce<number[]>((acc, episode) => {
const airDateUtc = episode.airDateUtc;
if (
!episode.episodeFileId &&
moment(airDateUtc).isAfter(start) &&
moment(airDateUtc).isBefore(end) &&
isBefore(episode.airDateUtc) &&
!queueDetails.some(
(details) => !!details.episode && details.episode.id === episode.id
)
) {
acc.push(episode.id);
}
return acc;
}, []);
}
);
}
function createIsSearchingSelector() {
return createSelector(
(state: AppState) => state.calendar.searchMissingCommandId,
createCommandsSelector(),
(searchMissingCommandId, commands) => {
if (searchMissingCommandId == null) {
return false;
}
return isCommandExecuting(
commands.find((command) => {
return command.id === searchMissingCommandId;
})
);
}
);
}
function CalendarPage() { function CalendarPage() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { selectedFilterKey, filters } = useSelector( const { selectedFilterKey, filters, items } = useSelector(
(state: AppState) => state.calendar (state: AppState) => state.calendar
); );
const missingEpisodeIds = useSelector(createMissingEpisodeIdsSelector());
const isSearchingForMissing = useSelector(createIsSearchingSelector());
const isRssSyncExecuting = useSelector( const isRssSyncExecuting = useSelector(
createCommandExecutingSelector(commandNames.RSS_SYNC) createCommandExecutingSelector(commandNames.RSS_SYNC)
); );
@@ -127,10 +77,6 @@ function CalendarPage() {
); );
}, [dispatch]); }, [dispatch]);
const handleSearchMissingPress = useCallback(() => {
dispatch(searchMissing({ episodeIds: missingEpisodeIds }));
}, [missingEpisodeIds, dispatch]);
const handleFilterSelect = useCallback( const handleFilterSelect = useCallback(
(key: string | number) => { (key: string | number) => {
dispatch(setCalendarFilter({ selectedFilterKey: key })); dispatch(setCalendarFilter({ selectedFilterKey: key }));
@@ -138,6 +84,10 @@ function CalendarPage() {
[dispatch] [dispatch]
); );
const episodeIds = useMemo(() => {
return selectUniqueIds<Episode, number>(items, 'id');
}, [items]);
useEffect(() => { useEffect(() => {
if (width === 0) { if (width === 0) {
return; return;
@@ -152,71 +102,67 @@ function CalendarPage() {
}, [width, dispatch]); }, [width, dispatch]);
return ( return (
<PageContent title={translate('Calendar')}> <QueueDetails episodeIds={episodeIds}>
<PageToolbar> <PageContent title={translate('Calendar')}>
<PageToolbarSection> <PageToolbar>
<PageToolbarButton <PageToolbarSection>
label={translate('ICalLink')} <PageToolbarButton
iconName={icons.CALENDAR} label={translate('ICalLink')}
onPress={handleGetCalendarLinkPress} iconName={icons.CALENDAR}
/> onPress={handleGetCalendarLinkPress}
/>
<PageToolbarSeparator /> <PageToolbarSeparator />
<PageToolbarButton <PageToolbarButton
label={translate('RssSync')} label={translate('RssSync')}
iconName={icons.RSS} iconName={icons.RSS}
isSpinning={isRssSyncExecuting} isSpinning={isRssSyncExecuting}
onPress={handleRssSyncPress} onPress={handleRssSyncPress}
/> />
<PageToolbarButton <CalendarMissingEpisodeSearchButton />
label={translate('SearchForMissing')} </PageToolbarSection>
iconName={icons.SEARCH}
isDisabled={!missingEpisodeIds.length}
isSpinning={isSearchingForMissing}
onPress={handleSearchMissingPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}> <PageToolbarSection alignContent={align.RIGHT}>
<PageToolbarButton <PageToolbarButton
label={translate('Options')} label={translate('Options')}
iconName={icons.POSTER} iconName={icons.POSTER}
onPress={handleOptionsPress} onPress={handleOptionsPress}
/> />
<FilterMenu <FilterMenu
alignMenu={align.RIGHT} alignMenu={align.RIGHT}
isDisabled={!hasSeries} isDisabled={!hasSeries}
selectedFilterKey={selectedFilterKey} selectedFilterKey={selectedFilterKey}
filters={filters} filters={filters}
customFilters={customFilters} customFilters={customFilters}
filterModalConnectorComponent={CalendarFilterModal} filterModalConnectorComponent={CalendarFilterModal}
onFilterSelect={handleFilterSelect} onFilterSelect={handleFilterSelect}
/> />
</PageToolbarSection> </PageToolbarSection>
</PageToolbar> </PageToolbar>
<PageContentBody <PageContentBody
ref={pageContentRef} ref={pageContentRef}
className={styles.calendarPageBody} className={styles.calendarPageBody}
innerClassName={styles.calendarInnerPageBody} innerClassName={styles.calendarInnerPageBody}
> >
{isMeasured ? <PageComponent totalItems={0} /> : <div />} {isMeasured ? <PageComponent totalItems={0} /> : <div />}
{hasSeries && <Legend />} {hasSeries && <Legend />}
</PageContentBody> </PageContentBody>
<CalendarLinkModal <CalendarLinkModal
isOpen={isCalendarLinkModalOpen} isOpen={isCalendarLinkModalOpen}
onModalClose={handleGetCalendarLinkModalClose} onModalClose={handleGetCalendarLinkModalClose}
/> />
<CalendarOptionsModal <CalendarOptionsModal
isOpen={isOptionsModalOpen} isOpen={isOptionsModalOpen}
onModalClose={handleOptionsModalClose} onModalClose={handleOptionsModalClose}
/> />
</PageContent> </PageContent>
</QueueDetails>
); );
} }
@@ -2,6 +2,7 @@ import classNames from 'classnames';
import moment from 'moment'; import moment from 'moment';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState'; import AppState from 'App/State/AppState';
import getStatusStyle from 'Calendar/getStatusStyle'; import getStatusStyle from 'Calendar/getStatusStyle';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
@@ -12,7 +13,6 @@ import getFinaleTypeName from 'Episode/getFinaleTypeName';
import useEpisodeFile from 'EpisodeFile/useEpisodeFile'; import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds } from 'Helpers/Props'; import { icons, kinds } from 'Helpers/Props';
import useSeries from 'Series/useSeries'; import useSeries from 'Series/useSeries';
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatTime from 'Utilities/Date/formatTime'; import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber'; import padNumber from 'Utilities/Number/padNumber';
@@ -58,7 +58,7 @@ function CalendarEvent(props: CalendarEventProps) {
const series = useSeries(seriesId); const series = useSeries(seriesId);
const episodeFile = useEpisodeFile(episodeFileId); const episodeFile = useEpisodeFile(episodeFileId);
const queueItem = useSelector(createQueueItemSelectorForHook(id)); const queueItem = useQueueItemForEpisode(id);
const { timeFormat, enableColorImpairedMode } = useSelector( const { timeFormat, enableColorImpairedMode } = useSelector(
createUISettingsSelector() createUISettingsSelector()
@@ -2,7 +2,7 @@ import classNames from 'classnames';
import moment from 'moment'; import moment from 'moment';
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { createSelector } from 'reselect'; import { useIsDownloadingEpisodes } from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState'; import AppState from 'App/State/AppState';
import getStatusStyle from 'Calendar/getStatusStyle'; import getStatusStyle from 'Calendar/getStatusStyle';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
@@ -18,17 +18,6 @@ import translate from 'Utilities/String/translate';
import CalendarEvent from './CalendarEvent'; import CalendarEvent from './CalendarEvent';
import styles from './CalendarEventGroup.css'; import styles from './CalendarEventGroup.css';
function createIsDownloadingSelector(episodeIds: number[]) {
return createSelector(
(state: AppState) => state.queue.details,
(details) => {
return details.items.some(
(item) => item.episodeId && episodeIds.includes(item.episodeId)
);
}
);
}
interface CalendarEventGroupProps { interface CalendarEventGroupProps {
episodeIds: number[]; episodeIds: number[];
seriesId: number; seriesId: number;
@@ -42,7 +31,7 @@ function CalendarEventGroup({
events, events,
onEventModalOpenToggle, onEventModalOpenToggle,
}: CalendarEventGroupProps) { }: CalendarEventGroupProps) {
const isDownloading = useSelector(createIsDownloadingSelector(episodeIds)); const isDownloading = useIsDownloadingEpisodes(episodeIds);
const series = useSeries(seriesId)!; const series = useSeries(seriesId)!;
const { timeFormat, enableColorImpairedMode } = useSelector( const { timeFormat, enableColorImpairedMode } = useSelector(
@@ -10,7 +10,7 @@ import {
interface CalendarEventQueueDetailsProps { interface CalendarEventQueueDetailsProps {
title: string; title: string;
size: number; size: number;
sizeleft: number; sizeLeft: number;
estimatedCompletionTime?: string; estimatedCompletionTime?: string;
status: string; status: string;
trackedDownloadState: QueueTrackedDownloadState; trackedDownloadState: QueueTrackedDownloadState;
@@ -22,7 +22,7 @@ interface CalendarEventQueueDetailsProps {
function CalendarEventQueueDetails({ function CalendarEventQueueDetails({
title, title,
size, size,
sizeleft, sizeLeft,
estimatedCompletionTime, estimatedCompletionTime,
status, status,
trackedDownloadState, trackedDownloadState,
@@ -30,13 +30,13 @@ function CalendarEventQueueDetails({
statusMessages, statusMessages,
errorMessage, errorMessage,
}: CalendarEventQueueDetailsProps) { }: CalendarEventQueueDetailsProps) {
const progress = size ? 100 - (sizeleft / size) * 100 : 0; const progress = size ? 100 - (sizeLeft / size) * 100 : 0;
return ( return (
<QueueDetails <QueueDetails
title={title} title={title}
size={size} size={size}
sizeleft={sizeleft} sizeLeft={sizeLeft}
estimatedCompletionTime={estimatedCompletionTime} estimatedCompletionTime={estimatedCompletionTime}
status={status} status={status}
trackedDownloadState={trackedDownloadState} trackedDownloadState={trackedDownloadState}
+32 -14
View File
@@ -3,19 +3,18 @@ import {
HubConnectionBuilder, HubConnectionBuilder,
LogLevel, LogLevel,
} from '@microsoft/signalr'; } from '@microsoft/signalr';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch } from 'react-redux';
import ModelBase from 'App/ModelBase'; import ModelBase from 'App/ModelBase';
import AppState from 'App/State/AppState';
import Command from 'Commands/Command'; import Command from 'Commands/Command';
import { setAppValue, setVersion } from 'Store/Actions/appActions'; import { setAppValue, setVersion } from 'Store/Actions/appActions';
import { removeItem, update, updateItem } from 'Store/Actions/baseActions'; import { removeItem, updateItem } from 'Store/Actions/baseActions';
import { import {
fetchCommands, fetchCommands,
finishCommand, finishCommand,
updateCommand, updateCommand,
} from 'Store/Actions/commandActions'; } from 'Store/Actions/commandActions';
import { fetchQueue, fetchQueueDetails } from 'Store/Actions/queueActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import { fetchSeries } from 'Store/Actions/seriesActions'; import { fetchSeries } from 'Store/Actions/seriesActions';
import { fetchQualityDefinitions } from 'Store/Actions/settingsActions'; import { fetchQualityDefinitions } from 'Store/Actions/settingsActions';
@@ -33,15 +32,13 @@ interface SignalRMessage {
resource: ModelBase; resource: ModelBase;
version: string; version: string;
}; };
version: number | undefined;
} }
function SignalRListener() { function SignalRListener() {
const queryClient = useQueryClient();
const dispatch = useDispatch(); const dispatch = useDispatch();
const isQueuePopulated = useSelector(
(state: AppState) => state.queue.paged.isPopulated
);
const connection = useRef<HubConnection | null>(null); const connection = useRef<HubConnection | null>(null);
const handleStartFail = useRef((error: unknown) => { const handleStartFail = useRef((error: unknown) => {
@@ -97,9 +94,14 @@ function SignalRListener() {
}); });
const handleReceiveMessage = useRef((message: SignalRMessage) => { const handleReceiveMessage = useRef((message: SignalRMessage) => {
console.debug('[signalR] received', message.name, message.body); console.debug(
`[signalR] received ${message.name}${
message.version ? ` v${message.version}` : ''
}`,
message.body
);
const { name, body } = message; const { name, body, version = 0 } = message;
if (name === 'calendar') { if (name === 'calendar') {
if (body.action === 'updated') { if (body.action === 'updated') {
@@ -235,20 +237,36 @@ function SignalRListener() {
} }
if (name === 'queue') { if (name === 'queue') {
if (isQueuePopulated) { if (version < 5) {
dispatch(fetchQueue()); return;
} }
queryClient.invalidateQueries({ queryKey: ['/queue'] });
return; return;
} }
if (name === 'queue/details') { if (name === 'queue/details') {
dispatch(fetchQueueDetails()); if (version < 5) {
return;
}
queryClient.invalidateQueries({ queryKey: ['/queue/details'] });
return; return;
} }
if (name === 'queue/status') { if (name === 'queue/status') {
dispatch(update({ section: 'queue.status', data: body.resource })); if (version < 5) {
return;
}
const statusDetails = queryClient.getQueriesData({
queryKey: ['/queue/status'],
});
statusDetails.forEach(([queryKey]) => {
queryClient.setQueryData(queryKey, () => body.resource);
});
return; return;
} }
@@ -20,7 +20,6 @@ function RelativeDateCell(props: RelativeDateCellProps) {
date, date,
includeSeconds = false, includeSeconds = false,
includeTime = false, includeTime = false,
component: Component = TableRowCell, component: Component = TableRowCell,
...otherProps ...otherProps
} = props; } = props;
+4 -5
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
import QueueDetails from 'Activity/Queue/QueueDetails'; import QueueDetails from 'Activity/Queue/QueueDetails';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import ProgressBar from 'Components/ProgressBar'; import ProgressBar from 'Components/ProgressBar';
@@ -7,7 +7,6 @@ import Episode from 'Episode/Episode';
import useEpisode, { EpisodeEntity } from 'Episode/useEpisode'; import useEpisode, { EpisodeEntity } from 'Episode/useEpisode';
import useEpisodeFile from 'EpisodeFile/useEpisodeFile'; import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds, sizes } from 'Helpers/Props'; import { icons, kinds, sizes } from 'Helpers/Props';
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
import isBefore from 'Utilities/Date/isBefore'; import isBefore from 'Utilities/Date/isBefore';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import EpisodeQuality from './EpisodeQuality'; import EpisodeQuality from './EpisodeQuality';
@@ -30,7 +29,7 @@ function EpisodeStatus({
grabbed = false, grabbed = false,
} = useEpisode(episodeId, episodeEntity) as Episode; } = useEpisode(episodeId, episodeEntity) as Episode;
const queueItem = useSelector(createQueueItemSelectorForHook(episodeId)); const queueItem = useQueueItemForEpisode(episodeId);
const episodeFile = useEpisodeFile(episodeFileId); const episodeFile = useEpisodeFile(episodeFileId);
const hasEpisodeFile = !!episodeFile; const hasEpisodeFile = !!episodeFile;
@@ -38,9 +37,9 @@ function EpisodeStatus({
const hasAired = isBefore(airDateUtc); const hasAired = isBefore(airDateUtc);
if (isQueued) { if (isQueued) {
const { sizeleft, size } = queueItem; const { sizeLeft, size } = queueItem;
const progress = size ? 100 - (sizeleft / size) * 100 : 0; const progress = size ? 100 - (sizeLeft / size) * 100 : 0;
return ( return (
<div className={styles.center}> <div className={styles.center}>
+82
View File
@@ -0,0 +1,82 @@
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Episode from './Episode';
export type EpisodeEntity =
| 'calendar'
| 'episodes'
| 'interactiveImport.episodes'
| 'wanted.cutoffUnmet'
| 'wanted.missing';
function getEpisodes(episodeIds: number[], episodes: Episode[]) {
return episodeIds.reduce<Episode[]>((acc, id) => {
const episode = episodes.find((episode) => episode.id === id);
if (episode) {
acc.push(episode);
}
return acc;
}, []);
}
function createEpisodeSelector(episodeIds: number[]) {
return createSelector(
(state: AppState) => state.episodes.items,
(episodes) => {
return getEpisodes(episodeIds, episodes);
}
);
}
function createCalendarEpisodeSelector(episodeIds: number[]) {
return createSelector(
(state: AppState) => state.calendar.items as Episode[],
(episodes) => {
return getEpisodes(episodeIds, episodes);
}
);
}
function createWantedCutoffUnmetEpisodeSelector(episodeIds: number[]) {
return createSelector(
(state: AppState) => state.wanted.cutoffUnmet.items,
(episodes) => {
return getEpisodes(episodeIds, episodes);
}
);
}
function createWantedMissingEpisodeSelector(episodeIds: number[]) {
return createSelector(
(state: AppState) => state.wanted.missing.items,
(episodes) => {
return getEpisodes(episodeIds, episodes);
}
);
}
export default function useEpisodes(
episodeIds: number[],
episodeEntity: EpisodeEntity
) {
let selector = createEpisodeSelector;
switch (episodeEntity) {
case 'calendar':
selector = createCalendarEpisodeSelector;
break;
case 'wanted.cutoffUnmet':
selector = createWantedCutoffUnmetEpisodeSelector;
break;
case 'wanted.missing':
selector = createWantedMissingEpisodeSelector;
break;
default:
break;
}
return useSelector(selector(episodeIds));
}
+10 -7
View File
@@ -1,22 +1,22 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query'; import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Error } from 'App/State/AppSectionState'; import { Error } from 'App/State/AppSectionState';
import fetchJson, { import fetchJson, { FetchJsonOptions } from 'Utilities/Fetch/fetchJson';
apiRoot, import getQueryPath from 'Utilities/Fetch/getQueryPath';
FetchJsonOptions, import getQueryString, { QueryParams } from 'Utilities/Fetch/getQueryString';
} from 'Utilities/Fetch/fetchJson';
interface MutationOptions<T, TData> interface MutationOptions<T, TData>
extends Omit<FetchJsonOptions<TData>, 'method'> { extends Omit<FetchJsonOptions<TData>, 'method'> {
method: 'POST' | 'PUT' | 'DELETE'; method: 'POST' | 'PUT' | 'DELETE';
mutationOptions?: Omit<UseMutationOptions<T, Error, TData>, 'mutationFn'>; mutationOptions?: Omit<UseMutationOptions<T, Error, TData>, 'mutationFn'>;
queryParams?: QueryParams;
} }
function useApiMutation<T, TData>(options: MutationOptions<T, TData>) { function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
const requestOptions = useMemo(() => { const requestOptions = useMemo(() => {
return { return {
...options, ...options,
path: apiRoot + options.path, path: getQueryPath(options.path) + getQueryString(options.queryParams),
headers: { headers: {
...options.headers, ...options.headers,
'X-Api-Key': window.Sonarr.apiKey, 'X-Api-Key': window.Sonarr.apiKey,
@@ -26,8 +26,11 @@ function useApiMutation<T, TData>(options: MutationOptions<T, TData>) {
return useMutation<T, Error, TData>({ return useMutation<T, Error, TData>({
...options.mutationOptions, ...options.mutationOptions,
mutationFn: async (data: TData) => mutationFn: async (data?: TData) => {
fetchJson<T, TData>({ ...requestOptions, body: data }), const { path, ...otherOptions } = requestOptions;
return fetchJson<T, TData>({ path, ...otherOptions, body: data });
},
}); });
} }
+10 -7
View File
@@ -15,22 +15,25 @@ export interface QueryOptions<T> extends FetchJsonOptions<unknown> {
} }
const useApiQuery = <T>(options: QueryOptions<T>) => { const useApiQuery = <T>(options: QueryOptions<T>) => {
const requestOptions = useMemo(() => { const { queryKey, requestOptions } = useMemo(() => {
const { path: path, queryOptions, queryParams, ...otherOptions } = options; const { path: path, queryOptions, queryParams, ...otherOptions } = options;
return { return {
...otherOptions, queryKey: [path, queryParams],
path: getQueryPath(path) + getQueryString(queryParams), requestOptions: {
headers: { ...otherOptions,
...options.headers, path: getQueryPath(path) + getQueryString(queryParams),
'X-Api-Key': window.Sonarr.apiKey, headers: {
...options.headers,
'X-Api-Key': window.Sonarr.apiKey,
},
}, },
}; };
}, [options]); }, [options]);
return useQuery({ return useQuery({
...options.queryOptions, ...options.queryOptions,
queryKey: [requestOptions.path], queryKey,
queryFn: async ({ signal }) => queryFn: async ({ signal }) =>
fetchJson<T, unknown>({ ...requestOptions, signal }), fetchJson<T, unknown>({ ...requestOptions, signal }),
}); });
@@ -0,0 +1,128 @@
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,
};
};
+2
View File
@@ -4,10 +4,12 @@ import { create } from 'zustand';
interface PageStore { interface PageStore {
events: number; events: number;
queue: number;
} }
const pageStore = create<PageStore>(() => ({ const pageStore = create<PageStore>(() => ({
events: 1, events: 1,
queue: 1,
})); }));
const usePage = (kind: keyof PageStore) => { const usePage = (kind: keyof PageStore) => {
+27 -16
View File
@@ -26,7 +26,7 @@ interface PagedQueryResponse<T> {
} }
const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => { const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
const requestOptions = useMemo(() => { const { requestOptions, queryKey } = useMemo(() => {
const { const {
path, path,
page, page,
@@ -40,27 +40,38 @@ const usePagedApiQuery = <T>(options: PagedQueryOptions<T>) => {
} = options; } = options;
return { return {
...otherOptions, queryKey: [
path: path,
getQueryPath(path) + queryParams,
getQueryString({ page,
...queryParams, pageSize,
page, sortKey,
pageSize, sortDirection,
sortKey, filters,
sortDirection, ],
filters, requestOptions: {
}), ...otherOptions,
headers: { path:
...options.headers, getQueryPath(path) +
'X-Api-Key': window.Sonarr.apiKey, getQueryString({
...queryParams,
page,
pageSize,
sortKey,
sortDirection,
filters,
}),
headers: {
...options.headers,
'X-Api-Key': window.Sonarr.apiKey,
},
}, },
}; };
}, [options]); }, [options]);
return useQuery({ return useQuery({
...options.queryOptions, ...options.queryOptions,
queryKey: [requestOptions.path], queryKey,
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
const response = await fetchJson<PagedQueryResponse<T>, unknown>({ const response = await fetchJson<PagedQueryResponse<T>, unknown>({
...requestOptions, ...requestOptions,
-54
View File
@@ -1,6 +1,5 @@
import { create, type StateCreator } from 'zustand'; import { create, type StateCreator } from 'zustand';
import { persist, type PersistOptions } from 'zustand/middleware'; import { persist, type PersistOptions } from 'zustand/middleware';
import Column from 'Components/Table/Column';
export const createPersist = <T>( export const createPersist = <T>(
name: string, name: string,
@@ -19,56 +18,3 @@ export const createPersist = <T>(
}) })
); );
}; };
export const mergeColumns = <T extends { columns: Column[] }>(
persistedState: unknown,
currentState: T
) => {
const currentColumns = currentState.columns;
const persistedColumns = (persistedState as T).columns;
const columns: Column[] = [];
// Add persisted columns in the same order they're currently in
// as long as they haven't been removed.
persistedColumns.forEach((persistedColumn) => {
const column = currentColumns.find((i) => i.name === persistedColumn.name);
if (column) {
const newColumn: Partial<Column> = {};
// We can't use a spread operator or Object.assign to clone the column
// or any accessors are lost and can break translations.
for (const prop of Object.keys(column)) {
const attributes = Object.getOwnPropertyDescriptor(column, prop);
if (!attributes) {
return;
}
Object.defineProperty(newColumn, prop, attributes);
}
newColumn.isVisible = persistedColumn.isVisible;
columns.push(newColumn as Column);
}
});
// Add any columns added to the app in the initial position.
currentColumns.forEach((currentColumn, index) => {
const persistedColumnIndex = persistedColumns.findIndex(
(i) => i.name === currentColumn.name
);
const column = Object.assign({}, currentColumn);
if (persistedColumnIndex === -1) {
columns.splice(index, 0, column);
}
});
return {
...(persistedState as T),
columns,
};
};
@@ -1,10 +1,7 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useQueueDetailsForSeries } from 'Activity/Queue/Details/QueueDetailsProvider';
import Label from 'Components/Label'; import Label from 'Components/Label';
import { kinds, sizes } from 'Helpers/Props'; import { kinds, sizes } from 'Helpers/Props';
import createSeriesQueueItemsDetailsSelector, {
SeriesQueueDetails,
} from 'Series/Index/createSeriesQueueDetailsSelector';
function getEpisodeCountKind( function getEpisodeCountKind(
monitored: boolean, monitored: boolean,
@@ -44,9 +41,7 @@ function SeasonProgressLabel({
episodeCount, episodeCount,
episodeFileCount, episodeFileCount,
}: SeasonProgressLabelProps) { }: SeasonProgressLabelProps) {
const queueDetails: SeriesQueueDetails = useSelector( const queueDetails = useQueueDetailsForSeries(seriesId, seasonNumber);
createSeriesQueueItemsDetailsSelector(seriesId, seasonNumber)
);
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles; const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
const text = newDownloads const text = newDownloads
+386 -380
View File
@@ -2,6 +2,7 @@ import moment from 'moment';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider';
import AppState from 'App/State/AppState'; import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames'; import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
@@ -47,10 +48,6 @@ import {
clearEpisodeFiles, clearEpisodeFiles,
fetchEpisodeFiles, fetchEpisodeFiles,
} from 'Store/Actions/episodeFileActions'; } from 'Store/Actions/episodeFileActions';
import {
clearQueueDetails,
fetchQueueDetails,
} from 'Store/Actions/queueActions';
import { toggleSeriesMonitored } from 'Store/Actions/seriesActions'; import { toggleSeriesMonitored } from 'Store/Actions/seriesActions';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
@@ -380,7 +377,6 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
const populate = useCallback(() => { const populate = useCallback(() => {
dispatch(fetchEpisodes({ seriesId })); dispatch(fetchEpisodes({ seriesId }));
dispatch(fetchEpisodeFiles({ seriesId })); dispatch(fetchEpisodeFiles({ seriesId }));
dispatch(fetchQueueDetails({ seriesId }));
}, [seriesId, dispatch]); }, [seriesId, dispatch]);
useEffect(() => { useEffect(() => {
@@ -394,7 +390,6 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
unregisterPagePopulator(populate); unregisterPagePopulator(populate);
dispatch(clearEpisodes()); dispatch(clearEpisodes());
dispatch(clearEpisodeFiles()); dispatch(clearEpisodeFiles());
dispatch(clearQueueDetails());
}; };
}, [populate, dispatch]); }, [populate, dispatch]);
@@ -466,424 +461,435 @@ function SeriesDetails({ seriesId }: SeriesDetailsProps) {
const isPopulated = isEpisodesPopulated && isEpisodeFilesPopulated; const isPopulated = isEpisodesPopulated && isEpisodeFilesPopulated;
return ( return (
<PageContent title={title}> <QueueDetailsProvider seriesId={seriesId}>
<PageToolbar> <PageContent title={title}>
<PageToolbarSection> <PageToolbar>
<PageToolbarButton <PageToolbarSection>
label={translate('RefreshAndScan')} <PageToolbarButton
iconName={icons.REFRESH} label={translate('RefreshAndScan')}
spinningName={icons.REFRESH} iconName={icons.REFRESH}
title={translate('RefreshAndScanTooltip')} spinningName={icons.REFRESH}
isSpinning={isRefreshing} title={translate('RefreshAndScanTooltip')}
onPress={handleRefreshPress} isSpinning={isRefreshing}
/> onPress={handleRefreshPress}
<PageToolbarButton
label={translate('SearchMonitored')}
iconName={icons.SEARCH}
isDisabled={!monitored || !hasMonitoredEpisodes || !hasEpisodes}
isSpinning={isSearching}
title={
hasMonitoredEpisodes
? undefined
: translate('NoMonitoredEpisodes')
}
onPress={handleSearchPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('PreviewRename')}
iconName={icons.ORGANIZE}
isDisabled={!hasEpisodeFiles}
onPress={handleOrganizePress}
/>
<PageToolbarButton
label={translate('ManageEpisodes')}
iconName={icons.EPISODE_FILE}
onPress={handleManageEpisodesPress}
/>
<PageToolbarButton
label={translate('History')}
iconName={icons.HISTORY}
isDisabled={!hasEpisodes}
onPress={handleSeriesHistoryPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('EpisodeMonitoring')}
iconName={icons.MONITORED}
onPress={handleMonitorOptionsPress}
/>
<PageToolbarButton
label={translate('Edit')}
iconName={icons.EDIT}
onPress={handleEditSeriesPress}
/>
<PageToolbarButton
label={translate('Delete')}
iconName={icons.DELETE}
onPress={handleDeleteSeriesPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<PageToolbarButton
label={
expandedState.allExpanded
? translate('CollapseAll')
: translate('ExpandAll')
}
iconName={expandIcon}
onPress={handleExpandAllPress}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody innerClassName={styles.innerContentBody}>
<div className={styles.header}>
<div
className={styles.backdrop}
style={
fanartUrl ? { backgroundImage: `url(${fanartUrl})` } : undefined
}
>
<div className={styles.backdropOverlay} />
</div>
<div className={styles.headerContent}>
<SeriesPoster
className={styles.poster}
images={images}
size={500}
lazy={false}
/> />
<div className={styles.info}> <PageToolbarButton
<div className={styles.titleRow}> label={translate('SearchMonitored')}
<div className={styles.titleContainer}> iconName={icons.SEARCH}
<div className={styles.toggleMonitoredContainer}> isDisabled={!monitored || !hasMonitoredEpisodes || !hasEpisodes}
<MonitorToggleButton isSpinning={isSearching}
className={styles.monitorToggleButton} title={
monitored={monitored} hasMonitoredEpisodes
isSaving={isSaving} ? undefined
size={40} : translate('NoMonitoredEpisodes')
onPress={handleMonitorTogglePress} }
/> onPress={handleSearchPress}
</div> />
<div className={styles.title}>{title}</div> <PageToolbarSeparator />
{alternateTitles.length ? ( <PageToolbarButton
<div className={styles.alternateTitlesIconContainer}> label={translate('PreviewRename')}
<Popover iconName={icons.ORGANIZE}
anchor={ isDisabled={!hasEpisodeFiles}
<Icon name={icons.ALTERNATE_TITLES} size={20} /> onPress={handleOrganizePress}
} />
title={translate('AlternateTitles')}
body={ <PageToolbarButton
<SeriesAlternateTitles label={translate('ManageEpisodes')}
alternateTitles={alternateTitles} iconName={icons.EPISODE_FILE}
/> onPress={handleManageEpisodesPress}
} />
position={tooltipPositions.BOTTOM}
<PageToolbarButton
label={translate('History')}
iconName={icons.HISTORY}
isDisabled={!hasEpisodes}
onPress={handleSeriesHistoryPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('EpisodeMonitoring')}
iconName={icons.MONITORED}
onPress={handleMonitorOptionsPress}
/>
<PageToolbarButton
label={translate('Edit')}
iconName={icons.EDIT}
onPress={handleEditSeriesPress}
/>
<PageToolbarButton
label={translate('Delete')}
iconName={icons.DELETE}
onPress={handleDeleteSeriesPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<PageToolbarButton
label={
expandedState.allExpanded
? translate('CollapseAll')
: translate('ExpandAll')
}
iconName={expandIcon}
onPress={handleExpandAllPress}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody innerClassName={styles.innerContentBody}>
<div className={styles.header}>
<div
className={styles.backdrop}
style={
fanartUrl ? { backgroundImage: `url(${fanartUrl})` } : undefined
}
>
<div className={styles.backdropOverlay} />
</div>
<div className={styles.headerContent}>
<SeriesPoster
className={styles.poster}
images={images}
size={500}
lazy={false}
/>
<div className={styles.info}>
<div className={styles.titleRow}>
<div className={styles.titleContainer}>
<div className={styles.toggleMonitoredContainer}>
<MonitorToggleButton
className={styles.monitorToggleButton}
monitored={monitored}
isSaving={isSaving}
size={40}
onPress={handleMonitorTogglePress}
/> />
</div> </div>
) : null}
</div>
<div className={styles.seriesNavigationButtons}> <div className={styles.title}>{title}</div>
{previousSeries ? (
<IconButton
className={styles.seriesNavigationButton}
name={icons.ARROW_LEFT}
size={30}
title={translate('SeriesDetailsGoTo', {
title: previousSeries.title,
})}
to={`/series/${previousSeries.titleSlug}`}
/>
) : null}
{nextSeries ? ( {alternateTitles.length ? (
<IconButton <div className={styles.alternateTitlesIconContainer}>
className={styles.seriesNavigationButton} <Popover
name={icons.ARROW_RIGHT} anchor={
size={30} <Icon name={icons.ALTERNATE_TITLES} size={20} />
title={translate('SeriesDetailsGoTo', { }
title: nextSeries.title, title={translate('AlternateTitles')}
})} body={
to={`/series/${nextSeries.titleSlug}`} <SeriesAlternateTitles
/> alternateTitles={alternateTitles}
) : null} />
</div> }
</div> position={tooltipPositions.BOTTOM}
/>
<div className={styles.details}>
<div>
{runtime ? (
<span className={styles.runtime}>
{translate('SeriesDetailsRuntime', { runtime })}
</span>
) : null}
{ratings.value ? (
<HeartRating
rating={ratings.value}
votes={ratings.votes}
iconSize={20}
/>
) : null}
<SeriesGenres className={styles.genres} genres={genres} />
<span>{runningYears}</span>
</div>
</div>
<div>
<Label className={styles.detailsLabel} size={sizes.LARGE}>
<div>
<Icon name={icons.FOLDER} size={17} />
<span className={styles.path}>{path}</span>
</div>
</Label>
<Tooltip
anchor={
<Label className={styles.detailsLabel} size={sizes.LARGE}>
<div>
<Icon name={icons.DRIVE} size={17} />
<span className={styles.sizeOnDisk}>
{formatBytes(sizeOnDisk)}
</span>
</div> </div>
</Label> ) : null}
}
tooltip={<span>{episodeFilesCountMessage}</span>}
kind={kinds.INVERSE}
position={tooltipPositions.BOTTOM}
/>
<Label
className={styles.detailsLabel}
title={translate('QualityProfile')}
size={sizes.LARGE}
>
<div>
<Icon name={icons.PROFILE} size={17} />
<span className={styles.qualityProfileName}>
<QualityProfileName qualityProfileId={qualityProfileId} />
</span>
</div> </div>
</Label>
<Label className={styles.detailsLabel} size={sizes.LARGE}> <div className={styles.seriesNavigationButtons}>
<div> {previousSeries ? (
<Icon <IconButton
name={monitored ? icons.MONITORED : icons.UNMONITORED} className={styles.seriesNavigationButton}
size={17} name={icons.ARROW_LEFT}
/> size={30}
<span className={styles.qualityProfileName}> title={translate('SeriesDetailsGoTo', {
{monitored title: previousSeries.title,
? translate('Monitored') })}
: translate('Unmonitored')} to={`/series/${previousSeries.titleSlug}`}
</span> />
) : null}
{nextSeries ? (
<IconButton
className={styles.seriesNavigationButton}
name={icons.ARROW_RIGHT}
size={30}
title={translate('SeriesDetailsGoTo', {
title: nextSeries.title,
})}
to={`/series/${nextSeries.titleSlug}`}
/>
) : null}
</div> </div>
</Label> </div>
<Label <div className={styles.details}>
className={styles.detailsLabel}
title={statusDetails.message}
size={sizes.LARGE}
kind={status === 'deleted' ? kinds.INVERSE : undefined}
>
<div> <div>
<Icon name={statusDetails.icon} size={17} /> {runtime ? (
<span className={styles.statusName}> <span className={styles.runtime}>
{statusDetails.title} {translate('SeriesDetailsRuntime', { runtime })}
</span>
</div>
</Label>
{originalLanguage?.name ? (
<Label
className={styles.detailsLabel}
title={translate('OriginalLanguage')}
size={sizes.LARGE}
>
<div>
<Icon name={icons.LANGUAGE} size={17} />
<span className={styles.originalLanguageName}>
{originalLanguage.name}
</span> </span>
</div> ) : null}
</Label>
) : null}
{network ? ( {ratings.value ? (
<Label <HeartRating
className={styles.detailsLabel} rating={ratings.value}
title={translate('Network')} votes={ratings.votes}
size={sizes.LARGE} iconSize={20}
> />
) : null}
<SeriesGenres className={styles.genres} genres={genres} />
<span>{runningYears}</span>
</div>
</div>
<div>
<Label className={styles.detailsLabel} size={sizes.LARGE}>
<div> <div>
<Icon name={icons.NETWORK} size={17} /> <Icon name={icons.FOLDER} size={17} />
<span className={styles.network}>{network}</span> <span className={styles.path}>{path}</span>
</div> </div>
</Label> </Label>
) : null}
<Tooltip
anchor={
<Label className={styles.detailsLabel} size={sizes.LARGE}>
<div>
<Icon name={icons.EXTERNAL_LINK} size={17} />
<span className={styles.links}>
{translate('Links')}
</span>
</div>
</Label>
}
tooltip={
<SeriesDetailsLinks
tvdbId={tvdbId}
tvMazeId={tvMazeId}
imdbId={imdbId}
tmdbId={tmdbId}
/>
}
kind={kinds.INVERSE}
position={tooltipPositions.BOTTOM}
/>
{tags.length ? (
<Tooltip <Tooltip
anchor={ anchor={
<Label className={styles.detailsLabel} size={sizes.LARGE}> <Label className={styles.detailsLabel} size={sizes.LARGE}>
<Icon name={icons.TAGS} size={17} /> <div>
<Icon name={icons.DRIVE} size={17} />
<span className={styles.tags}>{translate('Tags')}</span> <span className={styles.sizeOnDisk}>
{formatBytes(sizeOnDisk)}
</span>
</div>
</Label> </Label>
} }
tooltip={<SeriesTags seriesId={seriesId} />} tooltip={<span>{episodeFilesCountMessage}</span>}
kind={kinds.INVERSE} kind={kinds.INVERSE}
position={tooltipPositions.BOTTOM} position={tooltipPositions.BOTTOM}
/> />
) : null}
<SeriesProgressLabel <Label
className={styles.seriesProgressLabel} className={styles.detailsLabel}
seriesId={seriesId} title={translate('QualityProfile')}
monitored={monitored} size={sizes.LARGE}
episodeCount={episodeCount} >
episodeFileCount={episodeFileCount} <div>
/> <Icon name={icons.PROFILE} size={17} />
<span className={styles.qualityProfileName}>
<QualityProfileName
qualityProfileId={qualityProfileId}
/>
</span>
</div>
</Label>
<Label className={styles.detailsLabel} size={sizes.LARGE}>
<div>
<Icon
name={monitored ? icons.MONITORED : icons.UNMONITORED}
size={17}
/>
<span className={styles.qualityProfileName}>
{monitored
? translate('Monitored')
: translate('Unmonitored')}
</span>
</div>
</Label>
<Label
className={styles.detailsLabel}
title={statusDetails.message}
size={sizes.LARGE}
kind={status === 'deleted' ? kinds.INVERSE : undefined}
>
<div>
<Icon name={statusDetails.icon} size={17} />
<span className={styles.statusName}>
{statusDetails.title}
</span>
</div>
</Label>
{originalLanguage?.name ? (
<Label
className={styles.detailsLabel}
title={translate('OriginalLanguage')}
size={sizes.LARGE}
>
<div>
<Icon name={icons.LANGUAGE} size={17} />
<span className={styles.originalLanguageName}>
{originalLanguage.name}
</span>
</div>
</Label>
) : null}
{network ? (
<Label
className={styles.detailsLabel}
title={translate('Network')}
size={sizes.LARGE}
>
<div>
<Icon name={icons.NETWORK} size={17} />
<span className={styles.network}>{network}</span>
</div>
</Label>
) : null}
<Tooltip
anchor={
<Label className={styles.detailsLabel} size={sizes.LARGE}>
<div>
<Icon name={icons.EXTERNAL_LINK} size={17} />
<span className={styles.links}>
{translate('Links')}
</span>
</div>
</Label>
}
tooltip={
<SeriesDetailsLinks
tvdbId={tvdbId}
tvMazeId={tvMazeId}
imdbId={imdbId}
tmdbId={tmdbId}
/>
}
kind={kinds.INVERSE}
position={tooltipPositions.BOTTOM}
/>
{tags.length ? (
<Tooltip
anchor={
<Label
className={styles.detailsLabel}
size={sizes.LARGE}
>
<Icon name={icons.TAGS} size={17} />
<span className={styles.tags}>
{translate('Tags')}
</span>
</Label>
}
tooltip={<SeriesTags seriesId={seriesId} />}
kind={kinds.INVERSE}
position={tooltipPositions.BOTTOM}
/>
) : null}
<SeriesProgressLabel
className={styles.seriesProgressLabel}
seriesId={seriesId}
monitored={monitored}
episodeCount={episodeCount}
episodeFileCount={episodeFileCount}
/>
</div>
<div className={styles.overview}>{overview}</div>
<MetadataAttribution />
</div> </div>
<div className={styles.overview}>{overview}</div>
<MetadataAttribution />
</div> </div>
</div> </div>
</div>
<div className={styles.contentContainer}> <div className={styles.contentContainer}>
{!isPopulated && !episodesError && !episodeFilesError ? ( {!isPopulated && !episodesError && !episodeFilesError ? (
<LoadingIndicator /> <LoadingIndicator />
) : null} ) : null}
{!isFetching && episodesError ? ( {!isFetching && episodesError ? (
<Alert kind={kinds.DANGER}>{translate('EpisodesLoadError')}</Alert> <Alert kind={kinds.DANGER}>
) : null} {translate('EpisodesLoadError')}
</Alert>
) : null}
{!isFetching && episodeFilesError ? ( {!isFetching && episodeFilesError ? (
<Alert kind={kinds.DANGER}> <Alert kind={kinds.DANGER}>
{translate('EpisodeFilesLoadError')} {translate('EpisodeFilesLoadError')}
</Alert> </Alert>
) : null} ) : null}
{isPopulated && !!seasons.length ? ( {isPopulated && !!seasons.length ? (
<div> <div>
{seasons {seasons
.slice(0) .slice(0)
.reverse() .reverse()
.map((season) => { .map((season) => {
return ( return (
<SeriesDetailsSeason <SeriesDetailsSeason
key={season.seasonNumber} key={season.seasonNumber}
seriesId={seriesId} seriesId={seriesId}
{...season} {...season}
isExpanded={expandedState.seasons[season.seasonNumber]} isExpanded={expandedState.seasons[season.seasonNumber]}
onExpandPress={handleExpandPress} onExpandPress={handleExpandPress}
/> />
); );
})} })}
</div> </div>
) : null} ) : null}
{isPopulated && !seasons.length ? ( {isPopulated && !seasons.length ? (
<Alert kind={kinds.WARNING}> <Alert kind={kinds.WARNING}>
{translate('NoEpisodeInformation')} {translate('NoEpisodeInformation')}
</Alert> </Alert>
) : null} ) : null}
</div> </div>
<OrganizePreviewModal <OrganizePreviewModal
isOpen={isOrganizeModalOpen} isOpen={isOrganizeModalOpen}
seriesId={seriesId} seriesId={seriesId}
onModalClose={handleOrganizeModalClose} onModalClose={handleOrganizeModalClose}
/> />
<InteractiveImportModal <InteractiveImportModal
isOpen={isManageEpisodesOpen} isOpen={isManageEpisodesOpen}
seriesId={seriesId} seriesId={seriesId}
title={title} title={title}
folder={path} folder={path}
initialSortKey="relativePath" initialSortKey="relativePath"
initialSortDirection={sortDirections.DESCENDING} initialSortDirection={sortDirections.DESCENDING}
showSeries={false} showSeries={false}
allowSeriesChange={false} allowSeriesChange={false}
showDelete={true} showDelete={true}
showImportMode={false} showImportMode={false}
modalTitle={translate('ManageEpisodes')} modalTitle={translate('ManageEpisodes')}
onModalClose={handleManageEpisodesModalClose} onModalClose={handleManageEpisodesModalClose}
/> />
<SeriesHistoryModal <SeriesHistoryModal
isOpen={isSeriesHistoryModalOpen} isOpen={isSeriesHistoryModalOpen}
seriesId={seriesId} seriesId={seriesId}
onModalClose={handleSeriesHistoryModalClose} onModalClose={handleSeriesHistoryModalClose}
/> />
<EditSeriesModal <EditSeriesModal
isOpen={isEditSeriesModalOpen} isOpen={isEditSeriesModalOpen}
seriesId={seriesId} seriesId={seriesId}
onModalClose={handleEditSeriesModalClose} onModalClose={handleEditSeriesModalClose}
onDeleteSeriesPress={handleDeleteSeriesPress} onDeleteSeriesPress={handleDeleteSeriesPress}
/> />
<DeleteSeriesModal <DeleteSeriesModal
isOpen={isDeleteSeriesModalOpen} isOpen={isDeleteSeriesModalOpen}
seriesId={seriesId} seriesId={seriesId}
onModalClose={handleDeleteSeriesModalClose} onModalClose={handleDeleteSeriesModalClose}
/> />
<MonitoringOptionsModal <MonitoringOptionsModal
isOpen={isMonitorOptionsModalOpen} isOpen={isMonitorOptionsModalOpen}
seriesId={seriesId} seriesId={seriesId}
onModalClose={handleMonitorOptionsClose} onModalClose={handleMonitorOptionsClose}
/> />
</PageContentBody> </PageContentBody>
</PageContent> </PageContent>
</QueueDetailsProvider>
); );
} }
@@ -1,10 +1,7 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useQueueDetailsForSeries } from 'Activity/Queue/Details/QueueDetailsProvider';
import Label from 'Components/Label'; import Label from 'Components/Label';
import { kinds, sizes } from 'Helpers/Props'; import { kinds, sizes } from 'Helpers/Props';
import createSeriesQueueItemsDetailsSelector, {
SeriesQueueDetails,
} from 'Series/Index/createSeriesQueueDetailsSelector';
function getEpisodeCountKind( function getEpisodeCountKind(
monitored: boolean, monitored: boolean,
@@ -42,9 +39,7 @@ function SeriesProgressLabel({
episodeCount, episodeCount,
episodeFileCount, episodeFileCount,
}: SeriesProgressLabelProps) { }: SeriesProgressLabelProps) {
const queueDetails: SeriesQueueDetails = useSelector( const queueDetails = useQueueDetailsForSeries(seriesId);
createSeriesQueueItemsDetailsSelector(seriesId)
);
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles; const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
const text = newDownloads const text = newDownloads
@@ -1,6 +1,7 @@
import classNames from 'classnames'; import classNames from 'classnames';
import React, { useCallback, useState } from 'react'; import React, { SyntheticEvent, useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useSelect } from 'App/SelectContext';
import { REFRESH_SERIES, SERIES_SEARCH } from 'Commands/commandNames'; import { REFRESH_SERIES, SERIES_SEARCH } from 'Commands/commandNames';
import Label from 'Components/Label'; import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
@@ -122,8 +123,31 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
setIsDeleteSeriesModalOpen(false); setIsDeleteSeriesModalOpen(false);
}, [setIsDeleteSeriesModalOpen]); }, [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 link = `/series/${titleSlug}`;
const linkProps = isSelectMode ? { onPress: onSelectPress } : { to: link };
const elementStyle = { const elementStyle = {
width: `${posterWidth}px`, width: `${posterWidth}px`,
height: `${posterHeight}px`, height: `${posterHeight}px`,
@@ -175,7 +199,7 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
/> />
) : null} ) : null}
<Link className={styles.link} style={elementStyle} to={link}> <Link className={styles.link} style={elementStyle} {...linkProps}>
<SeriesPoster <SeriesPoster
style={elementStyle} style={elementStyle}
images={images} images={images}
@@ -1,10 +1,7 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useQueueDetailsForSeries } from 'Activity/Queue/Details/QueueDetailsProvider';
import ProgressBar from 'Components/ProgressBar'; import ProgressBar from 'Components/ProgressBar';
import { sizes } from 'Helpers/Props'; import { sizes } from 'Helpers/Props';
import createSeriesQueueItemsDetailsSelector, {
SeriesQueueDetails,
} from 'Series/Index/createSeriesQueueDetailsSelector';
import { SeriesStatus } from 'Series/Series'; import { SeriesStatus } from 'Series/Series';
import getProgressBarKind from 'Utilities/Series/getProgressBarKind'; import getProgressBarKind from 'Utilities/Series/getProgressBarKind';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
@@ -37,9 +34,7 @@ function SeriesIndexProgressBar(props: SeriesIndexProgressBarProps) {
isStandalone, isStandalone,
} = props; } = props;
const queueDetails: SeriesQueueDetails = useSelector( const queueDetails = useQueueDetailsForSeries(seriesId, seasonNumber);
createSeriesQueueItemsDetailsSelector(seriesId, seasonNumber)
);
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles; const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
const progress = episodeCount ? (episodeFileCount / episodeCount) * 100 : 100; const progress = episodeCount ? (episodeFileCount / episodeCount) * 100 : 100;
+133 -130
View File
@@ -6,6 +6,7 @@ import React, {
useState, useState,
} from 'react'; } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider';
import { SelectProvider } from 'App/SelectContext'; import { SelectProvider } from 'App/SelectContext';
import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState'; import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState';
import SeriesAppState, { SeriesIndexAppState } from 'App/State/SeriesAppState'; import SeriesAppState, { SeriesIndexAppState } from 'App/State/SeriesAppState';
@@ -26,7 +27,6 @@ import { DESCENDING } from 'Helpers/Props/sortDirections';
import ParseToolbarButton from 'Parse/ParseToolbarButton'; import ParseToolbarButton from 'Parse/ParseToolbarButton';
import NoSeries from 'Series/NoSeries'; import NoSeries from 'Series/NoSeries';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import { fetchQueueDetails } from 'Store/Actions/queueActions';
import { fetchSeries } from 'Store/Actions/seriesActions'; import { fetchSeries } from 'Store/Actions/seriesActions';
import { import {
setSeriesFilter, setSeriesFilter,
@@ -104,7 +104,6 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
useEffect(() => { useEffect(() => {
dispatch(fetchSeries()); dispatch(fetchSeries());
dispatch(fetchQueueDetails({ all: true }));
}, [dispatch]); }, [dispatch]);
const onRssSyncPress = useCallback(() => { const onRssSyncPress = useCallback(() => {
@@ -217,155 +216,159 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
const hasNoSeries = !totalItems; const hasNoSeries = !totalItems;
return ( return (
<SelectProvider items={items}> <QueueDetailsProvider all={true}>
<PageContent> <SelectProvider items={items}>
<PageToolbar> <PageContent>
<PageToolbarSection> <PageToolbar>
<SeriesIndexRefreshSeriesButton <PageToolbarSection>
isSelectMode={isSelectMode} <SeriesIndexRefreshSeriesButton
selectedFilterKey={selectedFilterKey} isSelectMode={isSelectMode}
/> selectedFilterKey={selectedFilterKey}
/>
<PageToolbarButton <PageToolbarButton
label={translate('RssSync')} label={translate('RssSync')}
iconName={icons.RSS} iconName={icons.RSS}
isSpinning={isRssSyncExecuting} isSpinning={isRssSyncExecuting}
isDisabled={hasNoSeries} isDisabled={hasNoSeries}
onPress={onRssSyncPress} onPress={onRssSyncPress}
/> />
<PageToolbarSeparator /> <PageToolbarSeparator />
<SeriesIndexSelectModeButton <SeriesIndexSelectModeButton
label={ label={
isSelectMode isSelectMode
? translate('StopSelecting') ? translate('StopSelecting')
: translate('SelectSeries') : translate('SelectSeries')
} }
iconName={isSelectMode ? icons.SERIES_ENDED : icons.CHECK} iconName={isSelectMode ? icons.SERIES_ENDED : icons.CHECK}
isSelectMode={isSelectMode} isSelectMode={isSelectMode}
overflowComponent={SeriesIndexSelectModeMenuItem} overflowComponent={SeriesIndexSelectModeMenuItem}
onPress={onSelectModePress} onPress={onSelectModePress}
/> />
<SeriesIndexSelectAllButton <SeriesIndexSelectAllButton
label="SelectAll" label="SelectAll"
isSelectMode={isSelectMode} isSelectMode={isSelectMode}
overflowComponent={SeriesIndexSelectAllMenuItem} overflowComponent={SeriesIndexSelectAllMenuItem}
/> />
<PageToolbarSeparator /> <PageToolbarSeparator />
<ParseToolbarButton /> <ParseToolbarButton />
</PageToolbarSection> </PageToolbarSection>
<PageToolbarSection <PageToolbarSection
alignContent={align.RIGHT} alignContent={align.RIGHT}
collapseButtons={false} collapseButtons={false}
> >
{view === 'table' ? ( {view === 'table' ? (
<TableOptionsModalWrapper <TableOptionsModalWrapper
columns={columns} columns={columns}
optionsComponent={SeriesIndexTableOptions} optionsComponent={SeriesIndexTableOptions}
onTableOptionChange={onTableOptionChange} onTableOptionChange={onTableOptionChange}
> >
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
) : (
<PageToolbarButton <PageToolbarButton
label={translate('Options')} label={translate('Options')}
iconName={icons.TABLE} iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW}
isDisabled={hasNoSeries}
onPress={onOptionsPress}
/> />
</TableOptionsModalWrapper> )}
) : (
<PageToolbarButton <PageToolbarSeparator />
label={translate('Options')}
iconName={view === 'posters' ? icons.POSTER : icons.OVERVIEW} <SeriesIndexViewMenu
view={view}
isDisabled={hasNoSeries} isDisabled={hasNoSeries}
onPress={onOptionsPress} onViewSelect={onViewSelect}
/> />
)}
<PageToolbarSeparator /> <SeriesIndexSortMenu
sortKey={sortKey}
sortDirection={sortDirection}
isDisabled={hasNoSeries}
onSortSelect={onSortSelect}
/>
<SeriesIndexViewMenu <SeriesIndexFilterMenu
view={view} selectedFilterKey={selectedFilterKey}
isDisabled={hasNoSeries} filters={filters}
onViewSelect={onViewSelect} customFilters={customFilters}
/> isDisabled={hasNoSeries}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<div className={styles.pageContentBodyWrapper}>
<PageContentBody
ref={scrollerRef}
className={styles.contentBody}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
innerClassName={styles[`${view}InnerContentBody`]}
initialScrollTop={props.initialScrollTop}
onScroll={onScroll}
>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
<SeriesIndexSortMenu {!isFetching && !!error ? (
sortKey={sortKey} <Alert kind={kinds.DANGER}>
sortDirection={sortDirection} {translate('SeriesLoadError')}
isDisabled={hasNoSeries} </Alert>
onSortSelect={onSortSelect} ) : null}
/>
<SeriesIndexFilterMenu {isLoaded ? (
selectedFilterKey={selectedFilterKey} <div className={styles.contentBodyContainer}>
filters={filters} <ViewComponent
customFilters={customFilters} scrollerRef={scrollerRef}
isDisabled={hasNoSeries} items={items}
onFilterSelect={onFilterSelect} sortKey={sortKey}
/> sortDirection={sortDirection}
</PageToolbarSection> jumpToCharacter={jumpToCharacter}
</PageToolbar> isSelectMode={isSelectMode}
<div className={styles.pageContentBodyWrapper}> isSmallScreen={isSmallScreen}
<PageContentBody />
ref={scrollerRef}
className={styles.contentBody}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
innerClassName={styles[`${view}InnerContentBody`]}
initialScrollTop={props.initialScrollTop}
onScroll={onScroll}
>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isFetching && !!error ? ( <SeriesIndexFooter />
<Alert kind={kinds.DANGER}>{translate('SeriesLoadError')}</Alert> </div>
) : null}
{!error && isPopulated && !items.length ? (
<NoSeries totalItems={totalItems} />
) : null}
</PageContentBody>
{isLoaded && !!jumpBarItems.order.length ? (
<PageJumpBar
items={jumpBarItems}
onItemPress={onJumpBarItemPress}
/>
) : null} ) : null}
</div>
{isLoaded ? ( {isSelectMode ? <SeriesIndexSelectFooter /> : null}
<div className={styles.contentBodyContainer}>
<ViewComponent
scrollerRef={scrollerRef}
items={items}
sortKey={sortKey}
sortDirection={sortDirection}
jumpToCharacter={jumpToCharacter}
isSelectMode={isSelectMode}
isSmallScreen={isSmallScreen}
/>
<SeriesIndexFooter /> {view === 'posters' ? (
</div> <SeriesIndexPosterOptionsModal
) : null} isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
{!error && isPopulated && !items.length ? (
<NoSeries totalItems={totalItems} />
) : null}
</PageContentBody>
{isLoaded && !!jumpBarItems.order.length ? (
<PageJumpBar
items={jumpBarItems}
onItemPress={onJumpBarItemPress}
/> />
) : null} ) : null}
</div> {view === 'overview' ? (
<SeriesIndexOverviewOptionsModal
{isSelectMode ? <SeriesIndexSelectFooter /> : null} isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
{view === 'posters' ? ( />
<SeriesIndexPosterOptionsModal ) : null}
isOpen={isOptionsModalOpen} </PageContent>
onModalClose={onOptionsModalClose} </SelectProvider>
/> </QueueDetailsProvider>
) : null}
{view === 'overview' ? (
<SeriesIndexOverviewOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={onOptionsModalClose}
/>
) : null}
</PageContent>
</SelectProvider>
); );
}, 'seriesIndex'); }, 'seriesIndex');
@@ -1,46 +0,0 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
export interface SeriesQueueDetails {
count: number;
episodesWithFiles: number;
}
function createSeriesQueueDetailsSelector(
seriesId: number,
seasonNumber?: number
) {
return createSelector(
(state: AppState) => state.queue.details.items,
(queueItems) => {
return queueItems.reduce(
(acc: SeriesQueueDetails, item) => {
if (
item.trackedDownloadState === 'imported' ||
item.seriesId !== seriesId
) {
return acc;
}
if (seasonNumber != null && item.seasonNumber !== seasonNumber) {
return acc;
}
acc.count++;
if (item.episodeHasFile) {
acc.episodesWithFiles++;
}
return acc;
},
{
count: 0,
episodesWithFiles: 0,
}
);
}
);
}
export default createSeriesQueueDetailsSelector;
@@ -171,7 +171,7 @@ function HostSettings({
</FormGroup> </FormGroup>
) : null} ) : null}
{isWindowsService ? ( {isWindowsService ? null : (
<FormGroup size={sizes.MEDIUM}> <FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('OpenBrowserOnStart')}</FormLabel> <FormLabel>{translate('OpenBrowserOnStart')}</FormLabel>
@@ -183,7 +183,7 @@ function HostSettings({
{...launchBrowser} {...launchBrowser}
/> />
</FormGroup> </FormGroup>
) : null} )}
</FieldSet> </FieldSet>
); );
} }
-2
View File
@@ -16,7 +16,6 @@ import * as organizePreview from './organizePreviewActions';
import * as parse from './parseActions'; import * as parse from './parseActions';
import * as paths from './pathActions'; import * as paths from './pathActions';
import * as providerOptions from './providerOptionActions'; import * as providerOptions from './providerOptionActions';
import * as queue from './queueActions';
import * as releases from './releaseActions'; import * as releases from './releaseActions';
import * as rootFolders from './rootFolderActions'; import * as rootFolders from './rootFolderActions';
import * as series from './seriesActions'; import * as series from './seriesActions';
@@ -46,7 +45,6 @@ export default [
parse, parse,
paths, paths,
providerOptions, providerOptions,
queue,
releases, releases,
rootFolders, rootFolders,
series, series,
-562
View File
@@ -1,562 +0,0 @@
import _ from 'lodash';
import React from 'react';
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import Icon from 'Components/Icon';
import { filterBuilderTypes, filterBuilderValueTypes, icons, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import serverSideCollectionHandlers from 'Utilities/State/serverSideCollectionHandlers';
import translate from 'Utilities/String/translate';
import { set, updateItem } from './baseActions';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
import createClearReducer from './Creators/Reducers/createClearReducer';
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
//
// Variables
export const section = 'queue';
const status = `${section}.status`;
const details = `${section}.details`;
const paged = `${section}.paged`;
//
// State
export const defaultState = {
options: {
includeUnknownSeriesItems: true
},
removalOptions: {
removalMethod: 'removeFromClient',
blocklistMethod: 'doNotBlocklist'
},
status: {
isFetching: false,
isPopulated: false,
error: null,
item: {}
},
details: {
isFetching: false,
isPopulated: false,
error: null,
items: [],
params: {}
},
paged: {
isFetching: false,
isPopulated: false,
pageSize: 20,
sortKey: 'timeleft',
sortDirection: sortDirections.ASCENDING,
error: null,
items: [],
isGrabbing: false,
isRemoving: false,
columns: [
{
name: 'status',
columnLabel: () => translate('Status'),
isSortable: true,
isVisible: true,
isModifiable: false
},
{
name: 'series.sortTitle',
label: () => translate('Series'),
isSortable: true,
isVisible: true
},
{
name: 'episode',
label: () => translate('Episode'),
isSortable: true,
isVisible: true
},
{
name: 'episodes.title',
label: () => translate('EpisodeTitle'),
isSortable: true,
isVisible: true
},
{
name: 'episodes.airDateUtc',
label: () => translate('EpisodeAirDate'),
isSortable: true,
isVisible: false
},
{
name: 'languages',
label: () => translate('Languages'),
isSortable: true,
isVisible: false
},
{
name: 'quality',
label: () => translate('Quality'),
isSortable: true,
isVisible: true
},
{
name: 'customFormats',
label: () => translate('Formats'),
isSortable: false,
isVisible: true
},
{
name: 'customFormatScore',
columnLabel: () => translate('CustomFormatScore'),
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore')
}),
isVisible: false
},
{
name: 'protocol',
label: () => translate('Protocol'),
isSortable: true,
isVisible: false
},
{
name: 'indexer',
label: () => translate('Indexer'),
isSortable: true,
isVisible: false
},
{
name: 'downloadClient',
label: () => translate('DownloadClient'),
isSortable: true,
isVisible: false
},
{
name: 'title',
label: () => translate('ReleaseTitle'),
isSortable: true,
isVisible: false
},
{
name: 'size',
label: () => translate('Size'),
isSortable: true,
isVisible: false
},
{
name: 'outputPath',
label: () => translate('OutputPath'),
isSortable: false,
isVisible: false
},
{
name: 'estimatedCompletionTime',
label: () => translate('TimeLeft'),
isSortable: true,
isVisible: true
},
{
name: 'added',
label: () => translate('Added'),
isSortable: true,
isVisible: false
},
{
name: 'progress',
label: () => translate('Progress'),
isSortable: true,
isVisible: true
},
{
name: 'actions',
columnLabel: () => translate('Actions'),
isVisible: true,
isModifiable: false
}
],
selectedFilterKey: 'all',
filters: [
{
key: 'all',
label: 'All',
filters: []
}
],
filterBuilderProps: [
{
name: 'seriesIds',
label: () => translate('Series'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.SERIES
},
{
name: 'quality',
label: () => translate('Quality'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.QUALITY
},
{
name: 'languages',
label: () => translate('Languages'),
type: filterBuilderTypes.CONTAINS,
valueType: filterBuilderValueTypes.LANGUAGE
},
{
name: 'protocol',
label: () => translate('Protocol'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.PROTOCOL
},
{
name: 'status',
label: () => translate('Status'),
type: filterBuilderTypes.EQUAL,
valueType: filterBuilderValueTypes.QUEUE_STATUS
}
]
}
};
export const persistState = [
'queue.options',
'queue.removalOptions',
'queue.paged.pageSize',
'queue.paged.sortKey',
'queue.paged.sortDirection',
'queue.paged.columns',
'queue.paged.selectedFilterKey'
];
//
// Helpers
function fetchDataAugmenter(getState, payload, data) {
data.includeUnknownSeriesItems = getState().queue.options.includeUnknownSeriesItems;
}
//
// Actions Types
export const FETCH_QUEUE_STATUS = 'queue/fetchQueueStatus';
export const FETCH_QUEUE_DETAILS = 'queue/fetchQueueDetails';
export const CLEAR_QUEUE_DETAILS = 'queue/clearQueueDetails';
export const FETCH_QUEUE = 'queue/fetchQueue';
export const GOTO_FIRST_QUEUE_PAGE = 'queue/gotoQueueFirstPage';
export const GOTO_PREVIOUS_QUEUE_PAGE = 'queue/gotoQueuePreviousPage';
export const GOTO_NEXT_QUEUE_PAGE = 'queue/gotoQueueNextPage';
export const GOTO_LAST_QUEUE_PAGE = 'queue/gotoQueueLastPage';
export const GOTO_QUEUE_PAGE = 'queue/gotoQueuePage';
export const SET_QUEUE_SORT = 'queue/setQueueSort';
export const SET_QUEUE_FILTER = 'queue/setQueueFilter';
export const SET_QUEUE_TABLE_OPTION = 'queue/setQueueTableOption';
export const SET_QUEUE_OPTION = 'queue/setQueueOption';
export const SET_QUEUE_REMOVAL_OPTION = 'queue/setQueueRemoveOption';
export const CLEAR_QUEUE = 'queue/clearQueue';
export const GRAB_QUEUE_ITEM = 'queue/grabQueueItem';
export const GRAB_QUEUE_ITEMS = 'queue/grabQueueItems';
export const REMOVE_QUEUE_ITEM = 'queue/removeQueueItem';
export const REMOVE_QUEUE_ITEMS = 'queue/removeQueueItems';
//
// Action Creators
export const fetchQueueStatus = createThunk(FETCH_QUEUE_STATUS);
export const fetchQueueDetails = createThunk(FETCH_QUEUE_DETAILS);
export const clearQueueDetails = createAction(CLEAR_QUEUE_DETAILS);
export const fetchQueue = createThunk(FETCH_QUEUE);
export const gotoQueueFirstPage = createThunk(GOTO_FIRST_QUEUE_PAGE);
export const gotoQueuePreviousPage = createThunk(GOTO_PREVIOUS_QUEUE_PAGE);
export const gotoQueueNextPage = createThunk(GOTO_NEXT_QUEUE_PAGE);
export const gotoQueueLastPage = createThunk(GOTO_LAST_QUEUE_PAGE);
export const gotoQueuePage = createThunk(GOTO_QUEUE_PAGE);
export const setQueueSort = createThunk(SET_QUEUE_SORT);
export const setQueueFilter = createThunk(SET_QUEUE_FILTER);
export const setQueueTableOption = createAction(SET_QUEUE_TABLE_OPTION);
export const setQueueOption = createAction(SET_QUEUE_OPTION);
export const setQueueRemovalOption = createAction(SET_QUEUE_REMOVAL_OPTION);
export const clearQueue = createAction(CLEAR_QUEUE);
export const grabQueueItem = createThunk(GRAB_QUEUE_ITEM);
export const grabQueueItems = createThunk(GRAB_QUEUE_ITEMS);
export const removeQueueItem = createThunk(REMOVE_QUEUE_ITEM);
export const removeQueueItems = createThunk(REMOVE_QUEUE_ITEMS);
//
// Helpers
const fetchQueueDetailsHelper = createFetchHandler(details, '/queue/details');
//
// Action Handlers
export const actionHandlers = handleThunks({
[FETCH_QUEUE_STATUS]: createFetchHandler(status, '/queue/status'),
[FETCH_QUEUE_DETAILS]: function(getState, payload, dispatch) {
let params = payload;
// If the payload params are empty try to get params from state.
if (params && !_.isEmpty(params)) {
dispatch(set({ section: details, params }));
} else {
params = getState().queue.details.params;
}
// Ensure there are params before trying to fetch the queue
// so we don't make a bad request to the server.
if (params && !_.isEmpty(params)) {
fetchQueueDetailsHelper(getState, params, dispatch);
}
},
...createServerSideCollectionHandlers(
paged,
'/queue',
fetchQueue,
{
[serverSideCollectionHandlers.FETCH]: FETCH_QUEUE,
[serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_QUEUE_PAGE,
[serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_QUEUE_PAGE,
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_QUEUE_PAGE,
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_QUEUE_PAGE,
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_QUEUE_PAGE,
[serverSideCollectionHandlers.SORT]: SET_QUEUE_SORT,
[serverSideCollectionHandlers.FILTER]: SET_QUEUE_FILTER
},
fetchDataAugmenter
),
[GRAB_QUEUE_ITEM]: function(getState, payload, dispatch) {
const id = payload.id;
dispatch(updateItem({ section: paged, id, isGrabbing: true }));
const promise = createAjaxRequest({
url: `/queue/grab/${id}`,
method: 'POST'
}).request;
promise.done((data) => {
dispatch(batchActions([
fetchQueue(),
set({
section: paged,
isGrabbing: false,
grabError: null
})
]));
});
promise.fail((xhr) => {
dispatch(updateItem({
section: paged,
id,
isGrabbing: false,
grabError: xhr
}));
});
},
[GRAB_QUEUE_ITEMS]: function(getState, payload, dispatch) {
const ids = payload.ids;
dispatch(batchActions([
...ids.map((id) => {
return updateItem({
section: paged,
id,
isGrabbing: true
});
}),
set({
section: paged,
isGrabbing: true
})
]));
const promise = createAjaxRequest({
url: '/queue/grab/bulk',
method: 'POST',
dataType: 'json',
data: JSON.stringify(payload)
}).request;
promise.done((data) => {
dispatch(fetchQueue());
dispatch(batchActions([
...ids.map((id) => {
return updateItem({
section: paged,
id,
isGrabbing: false,
grabError: null
});
}),
set({
section: paged,
isGrabbing: false,
grabError: null
})
]));
});
promise.fail((xhr) => {
dispatch(batchActions([
...ids.map((id) => {
return updateItem({
section: paged,
id,
isGrabbing: false,
grabError: null
});
}),
set({ section: paged, isGrabbing: false })
]));
});
},
[REMOVE_QUEUE_ITEM]: function(getState, payload, dispatch) {
const {
id,
remove,
blocklist,
skipRedownload,
changeCategory
} = payload;
dispatch(updateItem({ section: paged, id, isRemoving: true }));
const promise = createAjaxRequest({
url: `/queue/${id}?removeFromClient=${remove}&blocklist=${blocklist}&skipRedownload=${skipRedownload}&changeCategory=${changeCategory}`,
method: 'DELETE'
}).request;
promise.done((data) => {
dispatch(fetchQueue());
});
promise.fail((xhr) => {
dispatch(updateItem({ section: paged, id, isRemoving: false }));
});
},
[REMOVE_QUEUE_ITEMS]: function(getState, payload, dispatch) {
const {
ids,
remove,
blocklist,
skipRedownload,
changeCategory
} = payload;
dispatch(batchActions([
...ids.map((id) => {
return updateItem({
section: paged,
id,
isRemoving: true
});
}),
set({ section: paged, isRemoving: true })
]));
const promise = createAjaxRequest({
url: `/queue/bulk?removeFromClient=${remove}&blocklist=${blocklist}&skipRedownload=${skipRedownload}&changeCategory=${changeCategory}`,
method: 'DELETE',
dataType: 'json',
contentType: 'application/json',
data: JSON.stringify({ ids })
}).request;
promise.done((data) => {
// Don't use batchActions with thunks
dispatch(fetchQueue());
dispatch(set({ section: paged, isRemoving: false }));
});
promise.fail((xhr) => {
dispatch(batchActions([
...ids.map((id) => {
return updateItem({
section: paged,
id,
isRemoving: false
});
}),
set({ section: paged, isRemoving: false })
]));
});
}
});
//
// Reducers
export const reducers = createHandleActions({
[CLEAR_QUEUE_DETAILS]: createClearReducer(details, defaultState.details),
[SET_QUEUE_TABLE_OPTION]: createSetTableOptionReducer(paged),
[SET_QUEUE_OPTION]: function(state, { payload }) {
const queueOptions = state.options;
return {
...state,
options: {
...queueOptions,
...payload
}
};
},
[SET_QUEUE_REMOVAL_OPTION]: function(state, { payload }) {
const queueRemovalOptions = state.removalOptions;
return {
...state,
removalOptions: {
...queueRemovalOptions,
...payload
}
};
},
[CLEAR_QUEUE]: createClearReducer(paged, {
isFetching: false,
isPopulated: false,
error: null,
items: [],
totalPages: 0,
totalRecords: 0
})
}, defaultState, section);
@@ -1,31 +0,0 @@
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 Column from 'Components/Table/Column';
import { createPersist, mergeColumns } from 'Helpers/createPersist'; import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
import { SortDirection } from 'Helpers/Props/sortDirections'; import { SortDirection } from 'Helpers/Props/sortDirections';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
@@ -11,7 +11,7 @@ export interface EventOptions {
columns: Column[]; columns: Column[];
} }
const eventOptionsStore = createPersist<EventOptions>( const { useOptions, setOptions, setOption } = createOptionsStore<EventOptions>(
'event_options', 'event_options',
() => { () => {
return { return {
@@ -57,29 +57,9 @@ const eventOptionsStore = createPersist<EventOptions>(
}, },
], ],
}; };
},
{
merge: mergeColumns,
} }
); );
export const useEventOptions = () => { export const useEventOptions = useOptions;
return eventOptionsStore((state) => state); export const setEventOptions = setOptions;
}; export const setEventOption = setOption;
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,6 +81,10 @@ async function fetchJson<T, TData>({
throw new ApiError(path, response.status, response.statusText, body); throw new ApiError(path, response.status, response.statusText, body);
} }
if (response.status === 204) {
return {} as T;
}
return response.json() as T; return response.json() as T;
} }
+32 -19
View File
@@ -1,7 +1,13 @@
import { PropertyFilter } from 'App/State/AppState'; import { PropertyFilter } from 'App/State/AppState';
export interface QueryParams { export interface QueryParams {
[key: string]: string | number | boolean | PropertyFilter[] | undefined; [key: string]:
| string
| number
| boolean
| PropertyFilter[]
| number[]
| undefined;
} }
const getQueryString = (queryParams?: QueryParams) => { const getQueryString = (queryParams?: QueryParams) => {
@@ -9,27 +15,34 @@ const getQueryString = (queryParams?: QueryParams) => {
return ''; return '';
} }
const filteredParams = Object.keys(queryParams).reduce< const searchParams = Object.keys(queryParams).reduce<URLSearchParams>(
Record<string, string> (acc, key) => {
>((acc, key) => { const value = queryParams[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));
}
if (value == null) {
return acc; return acc;
} },
new URLSearchParams()
);
if (Array.isArray(value)) { const paramsString = searchParams.toString();
value.forEach((filter) => {
acc[filter.key] = String(filter.value);
});
} else {
acc[key] = String(value);
}
return acc;
}, {});
const paramsString = new URLSearchParams(filteredParams).toString();
return `?${paramsString}`; return `?${paramsString}`;
}; };
@@ -1,13 +1,25 @@
import KeysMatching from 'typings/Helpers/KeysMatching'; import KeysMatching from 'typings/Helpers/KeysMatching';
function selectUniqueIds<T, K>(items: T[], idProp: KeysMatching<T, K>) { function selectUniqueIds<T, K>(items: T[], idProp: KeysMatching<T, K>) {
return items.reduce((acc: K[], item) => { const result = items.reduce((acc: Set<K>, item) => {
if (item[idProp] && acc.indexOf(item[idProp] as K) === -1) { if (!item[idProp]) {
acc.push(item[idProp] as K); return acc;
}
const value = item[idProp] as K;
if (Array.isArray(value)) {
value.forEach((v) => {
acc.add(v);
});
} else {
acc.add(value);
} }
return acc; return acc;
}, []); }, new Set<K>());
return Array.from(result);
} }
export default selectUniqueIds; export default selectUniqueIds;
+5 -2
View File
@@ -29,8 +29,8 @@ interface Queue extends ModelBase {
customFormatScore: number; customFormatScore: number;
size: number; size: number;
title: string; title: string;
sizeleft: number; sizeLeft: number;
timeleft: string; timeLeft: string;
estimatedCompletionTime: string; estimatedCompletionTime: string;
added?: string; added?: string;
status: string; status: string;
@@ -45,8 +45,11 @@ interface Queue extends ModelBase {
episodeHasFile: boolean; episodeHasFile: boolean;
seriesId?: number; seriesId?: number;
episodeId?: number; episodeId?: number;
episodeIds: number[];
seasonNumber?: number; seasonNumber?: number;
seasonNumbers: number[];
downloadClientHasPostImportCategory: boolean; downloadClientHasPostImportCategory: boolean;
isFullSeason: boolean;
episode?: Episode; episode?: Episode;
} }
@@ -4,6 +4,8 @@ namespace NzbDrone.Common.Extensions
{ {
public static class DateTimeExtensions 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) public static bool InNextDays(this DateTime dateTime, int days)
{ {
return InNext(dateTime, new TimeSpan(days, 0, 0, 0)); return InNext(dateTime, new TimeSpan(days, 0, 0, 0));
@@ -43,5 +45,10 @@ namespace NzbDrone.Common.Extensions
{ {
return dateTime.AddTicks(-(dateTime.Ticks % TimeSpan.TicksPerSecond)); return dateTime.AddTicks(-(dateTime.Ticks % TimeSpan.TicksPerSecond));
} }
public static DateTime WithTicksFrom(this DateTime dateTime, DateTime other)
{
return dateTime.WithoutTicks().AddTicks(other.Ticks % TimeSpan.TicksPerSecond);
}
} }
} }
@@ -0,0 +1,88 @@
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 }); AddPending(id: 1, seasonNumber: 2, episodes: new[] { 3 });
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id)); var queueId = HashConverter.GetHashInt31($"pending-{1}");
Subject.RemovePendingQueueItems(queueId); Subject.RemovePendingQueueItems(queueId);
@@ -82,7 +82,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
AddPending(id: 3, seasonNumber: 2, episodes: new[] { 3 }); AddPending(id: 3, seasonNumber: 2, episodes: new[] { 3 });
AddPending(id: 4, 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)); var queueId = HashConverter.GetHashInt31($"pending-{3}");
Subject.RemovePendingQueueItems(queueId); Subject.RemovePendingQueueItems(queueId);
@@ -97,7 +97,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
AddPending(id: 3, seasonNumber: 3, episodes: new[] { 1 }); AddPending(id: 3, seasonNumber: 3, episodes: new[] { 1 });
AddPending(id: 4, 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)); var queueId = HashConverter.GetHashInt31($"pending-{1}");
Subject.RemovePendingQueueItems(queueId); Subject.RemovePendingQueueItems(queueId);
@@ -112,7 +112,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
AddPending(id: 3, seasonNumber: 2, episodes: new[] { 2 }); AddPending(id: 3, seasonNumber: 2, episodes: new[] { 2 });
AddPending(id: 4, seasonNumber: 2, episodes: new[] { 3 }); AddPending(id: 4, seasonNumber: 2, episodes: new[] { 3 });
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id)); var queueId = HashConverter.GetHashInt31($"pending-{1}");
Subject.RemovePendingQueueItems(queueId); Subject.RemovePendingQueueItems(queueId);
@@ -125,7 +125,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 }); AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 });
AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1, 2 }); AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1, 2 });
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id)); var queueId = HashConverter.GetHashInt31($"pending-{1}");
Subject.RemovePendingQueueItems(queueId); Subject.RemovePendingQueueItems(queueId);
@@ -138,7 +138,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 }); AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 });
AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1, 2 }); AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1, 2 });
var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 2, _episode.Id)); var queueId = HashConverter.GetHashInt31($"pending-{2}");
Subject.RemovePendingQueueItems(queueId); Subject.RemovePendingQueueItems(queueId);
@@ -0,0 +1,153 @@
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,6 +59,7 @@ namespace NzbDrone.Core.Test.IndexerTests
public void should_return_season_time_for_season_packs() public void should_return_season_time_for_season_packs()
{ {
var settings = new TorznabSettings(); var settings = new TorznabSettings();
settings.SeedCriteria.SeasonPackSeedGoal = (int)SeasonPackSeedGoal.UseSeasonPackSeedGoal;
settings.SeedCriteria.SeasonPackSeedTime = 10; settings.SeedCriteria.SeasonPackSeedTime = 10;
Mocker.GetMock<ICachedIndexerSettingsProvider>() Mocker.GetMock<ICachedIndexerSettingsProvider>()
@@ -85,5 +86,71 @@ namespace NzbDrone.Core.Test.IndexerTests
result.Should().NotBeNull(); result.Should().NotBeNull();
result.SeedTime.Should().Be(TimeSpan.FromMinutes(10)); 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);
}
} }
} }
@@ -0,0 +1,118 @@
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,6 +86,8 @@ namespace NzbDrone.Core.Test.ParserTests
[TestCase("Series Title S01 1080p Eng Fra [mkvonly]")] [TestCase("Series Title S01 1080p Eng Fra [mkvonly]")]
[TestCase("Series Title S01 Eng Fre Multi Subs 720p [H264 mp4]")] [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-[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) public void should_parse_language_french_english(string postTitle)
{ {
var result = LanguageParser.ParseLanguages(postTitle); var result = LanguageParser.ParseLanguages(postTitle);
@@ -145,6 +147,7 @@ namespace NzbDrone.Core.Test.ParserTests
} }
[TestCase("Title.the.Series.2009.S01E14.Japanese.HDTV.XviD-LOL")] [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) public void should_parse_language_japanese(string postTitle)
{ {
var result = LanguageParser.ParseLanguages(postTitle); var result = LanguageParser.ParseLanguages(postTitle);
@@ -50,17 +50,17 @@ namespace NzbDrone.Core.Test.ParserTests
[TestCase("Series Title S01 REMUX Dual Audio AVC 1080p 8-Bit-ZR-", "ZR")] [TestCase("Series Title S01 REMUX Dual Audio AVC 1080p 8-Bit-ZR-", "ZR")]
public void should_parse_release_group(string title, string expected) public void should_parse_release_group(string title, string expected)
{ {
Parser.Parser.ParseReleaseGroup(title).Should().Be(expected); Parser.ReleaseGroupParser.ParseReleaseGroup(title).Should().Be(expected);
} }
[TestCase("Show.Name.2009.S01.1080p.BluRay.DTS5.1.x264-D-Z0N3", "D-Z0N3")] [TestCase("Show.Name.2009.S01.1080p.BluRay.DTS5.1.x264-D-Z0N3", "D-Z0N3")]
[TestCase("Show.Name.S01E01.1080p.WEB-DL.H264.Fight-BB.mkv", "Fight-BB")] [TestCase("Show.Name.S01E01.1080p.WEB-DL.H264.Fight-BB.mkv", "Fight-BB")]
[TestCase("Show Name (2021) Season 1 S01 (1080p BluRay x265 HEVC 10bit AAC 5.1 Tigole) [QxR]", "Tigole")] [TestCase("Show Name (2021) Season 1 S01 (1080p BluRay x265 HEVC 10bit AAC 5.1 Tigole) [QxR]", "QxR")]
[TestCase("Show Name (2021) Season 1 S01 (1080p BluRay x265 HEVC 10bit AAC 2.0 afm72) [QxR]", "afm72")] [TestCase("Show Name (2021) Season 1 S01 (1080p BluRay x265 HEVC 10bit AAC 2.0 afm72) [QxR]", "QxR")]
[TestCase("Show Name (2021) Season 1 S01 (1080p DSNP WEB-DL x265 HEVC 10bit EAC3 5.1 Silence) [QxR]", "Silence")] [TestCase("Show Name (2021) Season 1 S01 (1080p DSNP WEB-DL x265 HEVC 10bit EAC3 5.1 Silence) [QxR]", "QxR")]
[TestCase("Show Name (2021) Season 1 S01 (1080p BluRay x265 HEVC 10bit AAC 2.0 Panda) [QxR]", "Panda")] [TestCase("Show Name (2021) Season 1 S01 (1080p BluRay x265 HEVC 10bit AAC 2.0 Panda) [QxR]", "QxR")]
[TestCase("Show Name (2020) Season 1 S01 (1080p AMZN WEB-DL x265 HEVC 10bit EAC3 2.0 Ghost) [QxR]", "Ghost")] [TestCase("Show Name (2020) Season 1 S01 (1080p AMZN WEB-DL x265 HEVC 10bit EAC3 2.0 Ghost) [QxR]", "QxR")]
[TestCase("Show Name (2020) Season 1 S01 (1080p WEB-DL x265 HEVC 10bit AC3 5.1 MONOLITH) [QxR]", "MONOLITH")] [TestCase("Show Name (2020) Season 1 S01 (1080p WEB-DL x265 HEVC 10bit AC3 5.1 MONOLITH) [QxR]", "QxR")]
[TestCase("The Show S08E09 The Series.1080p.AMZN.WEB-DL.x265.10bit.EAC3.6.0-Qman[UTR]", "UTR")] [TestCase("The Show S08E09 The Series.1080p.AMZN.WEB-DL.x265.10bit.EAC3.6.0-Qman[UTR]", "UTR")]
[TestCase("The Show S03E07 Fire and Series[1080p x265 10bit S87 Joy]", "Joy")] [TestCase("The Show S03E07 Fire and Series[1080p x265 10bit S87 Joy]", "Joy")]
[TestCase("The Show (2016) - S02E01 - Soul Series #1 (1080p NF WEBRip x265 ImE)", "ImE")] [TestCase("The Show (2016) - S02E01 - Soul Series #1 (1080p NF WEBRip x265 ImE)", "ImE")]
@@ -85,7 +85,7 @@ namespace NzbDrone.Core.Test.ParserTests
[TestCase("Series Title (2012) - S01E01 - Episode 1 (1080p BluRay x265 r00t).mkv", "r00t")] [TestCase("Series Title (2012) - S01E01 - Episode 1 (1080p BluRay x265 r00t).mkv", "r00t")]
[TestCase("Series Title - S01E01 - Girls Gone Wild Exposed (720p x265 EDGE2020).mkv", "EDGE2020")] [TestCase("Series Title - S01E01 - Girls Gone Wild Exposed (720p x265 EDGE2020).mkv", "EDGE2020")]
[TestCase("Series.Title.S01E02.1080p.BluRay.Remux.AVC.FLAC.2.0-E.N.D", "E.N.D")] [TestCase("Series.Title.S01E02.1080p.BluRay.Remux.AVC.FLAC.2.0-E.N.D", "E.N.D")]
[TestCase("Show Name (2016) Season 1 S01 (1080p AMZN WEB-DL x265 HEVC 10bit EAC3 5 1 RZeroX) QxR", "RZeroX")] [TestCase("Show Name (2016) Season 1 S01 (1080p AMZN WEB-DL x265 HEVC 10bit EAC3 5 1 RZeroX) QxR", "QxR")]
[TestCase("Series Title S01 1080p Blu-ray Remux AVC FLAC 2.0 - KRaLiMaRKo", "KRaLiMaRKo")] [TestCase("Series Title S01 1080p Blu-ray Remux AVC FLAC 2.0 - KRaLiMaRKo", "KRaLiMaRKo")]
[TestCase("Series Title S01 1080p Blu-ray Remux AVC DTS-HD MA 2.0 - BluDragon", "BluDragon")] [TestCase("Series Title S01 1080p Blu-ray Remux AVC DTS-HD MA 2.0 - BluDragon", "BluDragon")]
[TestCase("Example (2013) S01E01 (1080p iP WEBRip x265 SDR AAC 2.0 English - DarQ)", "DarQ")] [TestCase("Example (2013) S01E01 (1080p iP WEBRip x265 SDR AAC 2.0 English - DarQ)", "DarQ")]
@@ -95,9 +95,30 @@ namespace NzbDrone.Core.Test.ParserTests
[TestCase("Series.S01E05.1080p.WEB-DL.DDP5.1.H264-BEN.THE.MEN", "BEN.THE.MEN")] [TestCase("Series.S01E05.1080p.WEB-DL.DDP5.1.H264-BEN.THE.MEN", "BEN.THE.MEN")]
[TestCase("Series (2022) S01 (1080p BluRay x265 SDR DDP 5.1 English - JBENT TAoE)", "TAoE")] [TestCase("Series (2022) S01 (1080p BluRay x265 SDR DDP 5.1 English - JBENT TAoE)", "TAoE")]
[TestCase("Series (2005) S21E12 (1080p AMZN WEB-DL x265 SDR DDP 5.1 English - Goki TAoE)", "TAoE")] [TestCase("Series (2005) S21E12 (1080p AMZN WEB-DL x265 SDR DDP 5.1 English - Goki TAoE)", "TAoE")]
[TestCase("Series (2022) S03E12 (1080p AMZN Webrip x265 10 bit EAC3 5 1 - Ainz)[TAoE]", "TAoE")]
[TestCase("Series Things (2016) S04 Part 1 (1080p Webrip NF x265 10bit EAC3 5 1 - AJJMIN) [TAoE]", "TAoE")]
[TestCase("Series Soup (2024) S01 (1080p NF Webrip x265 10bit EAC3 5 1 Multi - ANONAZ)[TAoE]", "TAoE")]
[TestCase("Series (2022) S01 (1080p NF Webrip x265 10bit EAC3 5 1 Atmos - ArcX)[TAoE]", "TAoE")]
[TestCase("Series - King of Titles (2021) S01 (1080p HMAX Webrip x265 10bit AC3 5 1 - bccornfo) [TAoE]", "TAoE")]
[TestCase("Welcome to Series (2022) S04 (1080p AMZN Webrip x265 10bit EAC3 5 1 - DNU)[TAoE]", "TAoE")]
[TestCase("Series Who (2005) S01 (1080p BDRip x265 10bit AC3 5 1 - DrainedDay)[TAoE]", "TAoE")]
[TestCase("Series Down (2019) (1080p AMZN Webrip x265 10bit EAC3 5 1 - DUHiT)[TAoE]", "TAoE")]
[TestCase("Series (2016) S09 (1080p CRAV Webrip x265 10bit EAC3 5 1 - Erie) [TAoE]", "TAoE")]
[TestCase("Common Series Effects (2025) S01 (1080p AMZN Webrip x265 10bit EAC3 2 0 - Frys) [TAoE]", "TAoE")]
[TestCase("Murderbot (2025) S01 (2160p HDR10 DV Hybrid ATVP Webrip x265 10bit EAC3 5 1 Atmos - Goki)[TAoE]", "TAoE")]
[TestCase("Series In Real Life (2019) S01 REPACK (1080p DSNP Webrip x265 10bit AAC 2 0 - HxD)[TAoE]", "TAoE")]
[TestCase("Series Discovery (2017) S02 (1080p BDRip x265 10bit DTS-HD MA 5 1 - jb2049) [TAoE]", "TAoE")]
[TestCase("Series (2021) S03 (1080p DS4K NF Webrip x265 10bit EAC3 5 1 Atmos English - JBENT)[TAoE]", "TAoE")]
[TestCase("SuSeriespergirl (2015) S04 (1080p BDRip x265 10bit AC3 5 1 - Nostradamus)[TAoE]", "TAoE")]
[TestCase("Series (2019) S02 (4Kto1080p ATVP Webrip x265 10bit AC3 5 1 - r0b0t) [TAoE]", "TAoE")]
[TestCase("v (1970) S01 (2160p AIUS HDR10 DV Hybrid BDRip x265 10bit DTS-HD MA 5 1 - Species180) [TAoE]", "TAoE")]
[TestCase("Series (2024) S02 (1080p ATVP Webrip x265 10bit EAC3 5 1 - TheSickle)[TAoE]", "TAoE")]
[TestCase("Series (2016) S05 Part 02 (1080p NF Webrip x265 10bit EAC3 5 1 - xtrem3x) [TAoE]", "TAoE")]
[TestCase("Series (2013) S01 (1080p BDRip x265 10bit DTS-HD MA 5 1 - WEM)[TAoE]", "TAoE")]
[TestCase("The.Series.1989.S00E65.1080p.DSNP.Webrip.x265.10bit.EAC3.5.1.Goki.TAoE", "TAoE")]
public void should_parse_exception_release_group(string title, string expected) public void should_parse_exception_release_group(string title, string expected)
{ {
Parser.Parser.ParseReleaseGroup(title).Should().Be(expected); Parser.ReleaseGroupParser.ParseReleaseGroup(title).Should().Be(expected);
} }
[Test] [Test]
@@ -115,7 +136,7 @@ namespace NzbDrone.Core.Test.ParserTests
// [TestCase("", "")] // [TestCase("", "")]
public void should_not_include_language_in_release_group(string title, string expected) public void should_not_include_language_in_release_group(string title, string expected)
{ {
Parser.Parser.ParseReleaseGroup(title).Should().Be(expected); Parser.ReleaseGroupParser.ParseReleaseGroup(title).Should().Be(expected);
} }
[TestCase("Series.Title.S02E04.720p.WEB-DL.AAC2.0.H.264-EVL-RP", "EVL")] [TestCase("Series.Title.S02E04.720p.WEB-DL.AAC2.0.H.264-EVL-RP", "EVL")]
@@ -146,7 +167,7 @@ namespace NzbDrone.Core.Test.ParserTests
[TestCase("Series.Title.S04E06.Episode.Name.720p.WEB-DL.DD5.1.H.264-HarrHD-RePACKPOST", "HarrHD")] [TestCase("Series.Title.S04E06.Episode.Name.720p.WEB-DL.DD5.1.H.264-HarrHD-RePACKPOST", "HarrHD")]
public void should_not_include_repost_in_release_group(string title, string expected) public void should_not_include_repost_in_release_group(string title, string expected)
{ {
Parser.Parser.ParseReleaseGroup(title).Should().Be(expected); Parser.ReleaseGroupParser.ParseReleaseGroup(title).Should().Be(expected);
} }
[TestCase("[FFF] Series Title!! - S01E11 - Someday, With Sonarr", "FFF")] [TestCase("[FFF] Series Title!! - S01E11 - Someday, With Sonarr", "FFF")]
@@ -159,13 +180,13 @@ namespace NzbDrone.Core.Test.ParserTests
// [TestCase("", "")] // [TestCase("", "")]
public void should_parse_anime_release_groups(string title, string expected) public void should_parse_anime_release_groups(string title, string expected)
{ {
Parser.Parser.ParseReleaseGroup(title).Should().Be(expected); Parser.ReleaseGroupParser.ParseReleaseGroup(title).Should().Be(expected);
} }
[TestCase("Terrible.Anime.Title.001.DBOX.480p.x264-iKaos [v3] [6AFFEF6B]")] [TestCase("Terrible.Anime.Title.001.DBOX.480p.x264-iKaos [v3] [6AFFEF6B]")]
public void should_not_parse_anime_hash_as_release_group(string title) public void should_not_parse_anime_hash_as_release_group(string title)
{ {
Parser.Parser.ParseReleaseGroup(title).Should().BeNull(); Parser.ReleaseGroupParser.ParseReleaseGroup(title).Should().BeNull();
} }
} }
} }
@@ -0,0 +1,19 @@
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.ParserTests
{
[TestFixture]
public class SubGroupParserFixture : CoreTest
{
[TestCase("[GHOST][1080p] Series - 25 [BD HEVC 10bit Dual Audio AC3][AE0ADDBA]", "GHOST")]
public void should_parse_sub_group_from_title_as_release_group(string title, string expected)
{
var result = Parser.Parser.ParseTitle(title);
result.Should().NotBeNull();
result.ReleaseGroup.Should().Be(expected);
}
}
}
@@ -49,7 +49,7 @@ namespace NzbDrone.Core.Test.ParserTests
public void should_not_parse_url_in_group(string title, string expected) public void should_not_parse_url_in_group(string title, string expected)
{ {
Parser.Parser.ParseReleaseGroup(title).Should().Be(expected); Parser.ReleaseGroupParser.ParseReleaseGroup(title).Should().Be(expected);
} }
} }
} }
@@ -0,0 +1,70 @@
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,13 +58,9 @@ namespace NzbDrone.Core.Test.QueueTests
var queue = Subject.GetQueue(); var queue = Subject.GetQueue();
queue.Should().HaveCount(3); queue.Should().HaveCount(1);
queue.All(v => v.Id > 0).Should().BeTrue(); queue.All(v => v.Id > 0).Should().BeTrue();
var distinct = queue.Select(v => v.Id).Distinct().ToArray();
distinct.Should().HaveCount(3);
} }
} }
} }
@@ -12,6 +12,7 @@ using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Common.Serializer; using NzbDrone.Common.Serializer;
using NzbDrone.Core.Datastore.Migration.Framework; using NzbDrone.Core.Datastore.Migration.Framework;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.MediaFiles.MediaInfo; using NzbDrone.Core.MediaFiles.MediaInfo;
namespace NzbDrone.Core.Datastore.Migration namespace NzbDrone.Core.Datastore.Migration
@@ -809,7 +810,7 @@ namespace NzbDrone.Core.Datastore.Migration
private static string GetSceneNameMatch(string sceneName, params string[] tokens) private static string GetSceneNameMatch(string sceneName, params string[] tokens)
{ {
sceneName = sceneName.IsNotNullOrWhiteSpace() ? Parser.Parser.RemoveFileExtension(sceneName) : string.Empty; sceneName = sceneName.IsNotNullOrWhiteSpace() ? FileExtensions.RemoveFileExtension(sceneName) : string.Empty;
foreach (var token in tokens) foreach (var token in tokens)
{ {
@@ -0,0 +1,16 @@
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%'");
}
}
}
@@ -0,0 +1,66 @@
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);
}
}
}
}
@@ -0,0 +1,155 @@
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; }
}
}
@@ -0,0 +1,189 @@
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,
}
}
@@ -0,0 +1,298 @@
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 } }));
}
}
}
}
@@ -0,0 +1,119 @@
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;
}
}
}
@@ -0,0 +1,74 @@
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,6 +31,9 @@ namespace NzbDrone.Core.Download.Pending
Queue.Queue FindPendingQueueItem(int queueId); Queue.Queue FindPendingQueueItem(int queueId);
void RemovePendingQueueItems(int queueId); void RemovePendingQueueItems(int queueId);
RemoteEpisode OldestPendingRelease(int seriesId, int[] episodeIds); RemoteEpisode OldestPendingRelease(int seriesId, int[] episodeIds);
List<Queue.Queue> GetPendingQueueObsolete();
Queue.Queue FindPendingQueueItemObsolete(int queueId);
void RemovePendingQueueItemsObsolete(int queueId);
} }
public class PendingReleaseService : IPendingReleaseService, public class PendingReleaseService : IPendingReleaseService,
@@ -187,7 +190,44 @@ namespace NzbDrone.Core.Download.Pending
{ {
if (pendingRelease.RemoteEpisode.Episodes.Empty()) if (pendingRelease.RemoteEpisode.Episodes.Empty())
{ {
var noEpisodeItem = GetQueueItem(pendingRelease, nextRssSync, null); 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);
noEpisodeItem.ErrorMessage = "Unable to find matching episode(s)"; noEpisodeItem.ErrorMessage = "Unable to find matching episode(s)";
@@ -202,15 +242,18 @@ namespace NzbDrone.Core.Download.Pending
} }
} }
#pragma warning disable CS0612
// Return best quality release for each episode // Return best quality release for each episode
var deduped = queued.Where(q => q.Episode != null).GroupBy(q => q.Episode.Id).Select(g => var deduped = queued.Where(q => q.Episode != null).GroupBy(q => q.Episode.Id).Select(g =>
{ {
var series = g.First().Series; var series = g.First().Series;
return g.OrderByDescending(e => e.Quality, new QualityModelComparer(series.QualityProfile)) return g.OrderByDescending(e => e.Quality, new QualityModelComparer(series.QualityProfile))
.ThenBy(q => PrioritizeDownloadProtocol(q.Series, q.Protocol)) .ThenBy(q => PrioritizeDownloadProtocol(q.Series, q.Protocol))
.First(); .First();
}); });
#pragma warning restore CS0612
return deduped.ToList(); return deduped.ToList();
} }
@@ -220,6 +263,11 @@ namespace NzbDrone.Core.Download.Pending
return GetPendingQueue().SingleOrDefault(p => p.Id == queueId); 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) public void RemovePendingQueueItems(int queueId)
{ {
var targetItem = FindPendingRelease(queueId); var targetItem = FindPendingRelease(queueId);
@@ -232,6 +280,18 @@ namespace NzbDrone.Core.Download.Pending
_repository.DeleteMany(releasesToRemove.Select(c => c.Id)); _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) public RemoteEpisode OldestPendingRelease(int seriesId, int[] episodeIds)
{ {
var seriesReleases = GetPendingReleases(seriesId); var seriesReleases = GetPendingReleases(seriesId);
@@ -346,6 +406,59 @@ namespace NzbDrone.Core.Download.Pending
return result; 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) private Queue.Queue GetQueueItem(PendingRelease pendingRelease, Lazy<DateTime> nextRssSync, Episode episode)
{ {
var ect = pendingRelease.Release.PublishDate.AddMinutes(GetDelay(pendingRelease.RemoteEpisode)); var ect = pendingRelease.Release.PublishDate.AddMinutes(GetDelay(pendingRelease.RemoteEpisode));
@@ -380,7 +493,11 @@ namespace NzbDrone.Core.Download.Pending
{ {
Id = GetQueueId(pendingRelease, episode), Id = GetQueueId(pendingRelease, episode),
Series = pendingRelease.RemoteEpisode.Series, Series = pendingRelease.RemoteEpisode.Series,
#pragma warning disable CS0612
Episode = episode, Episode = episode,
#pragma warning restore CS0612
Languages = pendingRelease.RemoteEpisode.Languages, Languages = pendingRelease.RemoteEpisode.Languages,
Quality = pendingRelease.RemoteEpisode.ParsedEpisodeInfo.Quality, Quality = pendingRelease.RemoteEpisode.ParsedEpisodeInfo.Quality,
Title = pendingRelease.Title, Title = pendingRelease.Title,
@@ -484,10 +601,20 @@ namespace NzbDrone.Core.Download.Pending
} }
private PendingRelease FindPendingRelease(int queueId) 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))); 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) private int GetQueueId(PendingRelease pendingRelease, Episode episode)
{ {
return HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", pendingRelease.Id, episode?.Id ?? 0)); 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(); episodes = _episodeService.EpisodesWithoutFiles(pagingSpec).Records.ToList();
} }
var queue = _queueService.GetQueue().Where(q => q.Episode != null).Select(q => q.Episode.Id); var queue = GetQueuedEpisodeIds();
var missing = episodes.Where(e => !queue.Contains(e.Id)).ToList(); var missing = episodes.Where(e => !queue.Contains(e.Id)).ToList();
SearchForBulkEpisodes(missing, monitored, message.Trigger == CommandTrigger.Manual).GetAwaiter().GetResult(); SearchForBulkEpisodes(missing, monitored, message.Trigger == CommandTrigger.Manual).GetAwaiter().GetResult();
@@ -188,10 +188,18 @@ namespace NzbDrone.Core.IndexerSearch
} }
var episodes = _episodeCutoffService.EpisodesWhereCutoffUnmet(pagingSpec).Records.ToList(); var episodes = _episodeCutoffService.EpisodesWhereCutoffUnmet(pagingSpec).Records.ToList();
var queue = _queueService.GetQueue().Where(q => q.Episode != null).Select(q => q.Episode.Id); var queue = GetQueuedEpisodeIds();
var cutoffUnmet = episodes.Where(e => !queue.Contains(e.Id)).ToList(); var cutoffUnmet = episodes.Where(e => !queue.Contains(e.Id)).ToList();
SearchForBulkEpisodes(cutoffUnmet, monitored, message.Trigger == CommandTrigger.Manual).GetAwaiter().GetResult(); 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("DOGnzb", GetSettings("https://api.dognzb.cr"));
yield return GetDefinition("DrunkenSlug", GetSettings("https://drunkenslug.com")); yield return GetDefinition("DrunkenSlug", GetSettings("https://drunkenslug.com"));
yield return GetDefinition("Nzb.su", GetSettings("https://api.nzb.su")); yield return GetDefinition("Nzb.life", GetSettings("https://api.nzb.life"));
yield return GetDefinition("NZBCat", GetSettings("https://nzb.cat")); 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("NZBFinder.ws", GetSettings("https://nzbfinder.ws", categories: new[] { 5030, 5040, 5045 }));
yield return GetDefinition("NZBgeek", GetSettings("https://api.nzbgeek.info")); yield return GetDefinition("NZBgeek", GetSettings("https://api.nzbgeek.info"));
@@ -13,10 +13,10 @@ namespace NzbDrone.Core.Indexers.Newznab
{ {
public class NewznabSettingsValidator : AbstractValidator<NewznabSettings> public class NewznabSettingsValidator : AbstractValidator<NewznabSettings>
{ {
private static readonly string[] ApiKeyWhiteList = private static readonly string[] ApiKeyAllowList =
{ {
"nzbs.org", "nzbs.org",
"nzb.su", "nzb.life",
"dognzb.cr", "dognzb.cr",
"nzbplanet.net", "nzbplanet.net",
"nzbid.org", "nzbid.org",
@@ -26,7 +26,7 @@ namespace NzbDrone.Core.Indexers.Newznab
private static bool ShouldHaveApiKey(NewznabSettings settings) private static bool ShouldHaveApiKey(NewznabSettings settings)
{ {
return settings.BaseUrl != null && ApiKeyWhiteList.Any(c => settings.BaseUrl.ToLowerInvariant().Contains(c)); return settings.BaseUrl != null && ApiKeyAllowList.Any(c => settings.BaseUrl.ToLowerInvariant().Contains(c));
} }
private static readonly Regex AdditionalParametersRegex = new(@"(&.+?\=.+?)+", RegexOptions.Compiled); private static readonly Regex AdditionalParametersRegex = new(@"(&.+?\=.+?)+", RegexOptions.Compiled);
@@ -0,0 +1,11 @@
using NzbDrone.Core.Annotations;
namespace NzbDrone.Core.Indexers;
public enum SeasonPackSeedGoal
{
[FieldOption(Label = "IndexerSettingsSeasonPackSeedGoalUseStandardGoals")]
UseStandardSeedGoal = 0,
[FieldOption(Label = "IndexerSettingsSeasonPackSeedGoalUseSeasonPackGoals")]
UseSeasonPackSeedGoal = 1
}
@@ -49,12 +49,16 @@ namespace NzbDrone.Core.Indexers
return null; return null;
} }
var useSeasonPackSeedGoal = (SeasonPackSeedGoal)seedCriteria.SeasonPackSeedGoal == SeasonPackSeedGoal.UseSeasonPackSeedGoal;
var seedConfig = new TorrentSeedConfiguration var seedConfig = new TorrentSeedConfiguration
{ {
Ratio = seedCriteria.SeedRatio Ratio = (fullSeason && useSeasonPackSeedGoal)
? seedCriteria.SeasonPackSeedRatio
: seedCriteria.SeedRatio
}; };
var seedTime = fullSeason ? seedCriteria.SeasonPackSeedTime : seedCriteria.SeedTime; var seedTime = (fullSeason && useSeasonPackSeedGoal) ? seedCriteria.SeasonPackSeedTime : seedCriteria.SeedTime;
if (seedTime.HasValue) if (seedTime.HasValue)
{ {
seedConfig.SeedTime = TimeSpan.FromMinutes(seedTime.Value); seedConfig.SeedTime = TimeSpan.FromMinutes(seedTime.Value);
@@ -17,6 +17,10 @@ namespace NzbDrone.Core.Indexers
.When(c => c.SeedTime.HasValue) .When(c => c.SeedTime.HasValue)
.AsWarning().WithMessage("Should be greater than zero"); .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) RuleFor(c => c.SeasonPackSeedTime).GreaterThan(0)
.When(c => c.SeasonPackSeedTime.HasValue) .When(c => c.SeasonPackSeedTime.HasValue)
.AsWarning().WithMessage("Should be greater than zero"); .AsWarning().WithMessage("Should be greater than zero");
@@ -27,6 +31,11 @@ namespace NzbDrone.Core.Indexers
.When(c => c.SeedRatio > 0.0) .When(c => c.SeedRatio > 0.0)
.AsWarning() .AsWarning()
.WithMessage($"Under {seedRatioMinimum} leads to H&R"); .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) if (seedTimeMinimum != 0)
@@ -55,7 +64,13 @@ namespace NzbDrone.Core.Indexers
[FieldDefinition(1, Type = FieldType.Number, Label = "IndexerSettingsSeedTime", Unit = "minutes", HelpText = "IndexerSettingsSeedTimeHelpText", Advanced = true)] [FieldDefinition(1, Type = FieldType.Number, Label = "IndexerSettingsSeedTime", Unit = "minutes", HelpText = "IndexerSettingsSeedTimeHelpText", Advanced = true)]
public int? SeedTime { get; set; } public int? SeedTime { get; set; }
[FieldDefinition(2, Type = FieldType.Number, Label = "Season-Pack Seed Time", Unit = "minutes", HelpText = "IndexerSettingsSeasonPackSeedTimeHelpText", Advanced = true)] [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)]
public int? SeasonPackSeedTime { get; set; } public int? SeasonPackSeedTime { get; set; }
} }
} }
@@ -12,11 +12,11 @@ namespace NzbDrone.Core.Indexers.Torznab
{ {
public class TorznabSettingsValidator : AbstractValidator<TorznabSettings> public class TorznabSettingsValidator : AbstractValidator<TorznabSettings>
{ {
private static readonly string[] ApiKeyWhiteList = Array.Empty<string>(); private static readonly string[] ApiKeyAllowList = Array.Empty<string>();
private static bool ShouldHaveApiKey(TorznabSettings settings) private static bool ShouldHaveApiKey(TorznabSettings settings)
{ {
return settings.BaseUrl != null && ApiKeyWhiteList.Any(c => settings.BaseUrl.ToLowerInvariant().Contains(c)); return settings.BaseUrl != null && ApiKeyAllowList.Any(c => settings.BaseUrl.ToLowerInvariant().Contains(c));
} }
private static readonly Regex AdditionalParametersRegex = new(@"(&.+?\=.+?)+", RegexOptions.Compiled); private static readonly Regex AdditionalParametersRegex = new(@"(&.+?\=.+?)+", RegexOptions.Compiled);
+6 -2
View File
@@ -1874,7 +1874,6 @@
"DownloadClientQbittorrentValidationQueueingNotEnabledDetail": "La cua de torrent no està activada a la configuració del qBittorrent. Activeu-lo a qBittorrent o seleccioneu 'Last' com a prioritat.", "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ó", "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}", "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}.", "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}.", "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", "DownloadClientValidationSslConnectFailure": "No s'ha pogut connectar a través de SSL",
@@ -2166,5 +2165,10 @@
"NotificationsAppriseSettingsIncludePosterHelpText": "Inclou el cartell al missatge", "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", "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", "EpisodeMonitoring": "Monitoratge d'episodis",
"NotificationsAppriseSettingsIncludePoster": "Inclou el cartell" "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+)"
} }
@@ -537,7 +537,6 @@
"DownloadClientFreeboxUnableToReachFreebox": "Nelze se připojit k Freebox API. Zkontrolujte nastavení 'Host', 'Port' nebo 'Použít SSL'. (Chyba: {exceptionMessage})", "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.", "IndexerHDBitsSettingsCodecsHelpText": "Pokud nespecifikováno, použijí se všechny možnosti.",
"DownloadClientDownloadStationValidationSharedFolderMissing": "Sdílená složka neexistuje", "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í.", "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}", "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", "DownloadClientFloodSettingsRemovalInfo": "{appName} se postará o automatické mazání torrentů podle aktuálních kritérií seedování v Nastavení -> Indexery",
+19 -3
View File
@@ -549,7 +549,13 @@
"DownloadClientStatusSingleClientHealthCheckMessage": "Download clients unavailable due to failures: {downloadClientNames}", "DownloadClientStatusSingleClientHealthCheckMessage": "Download clients unavailable due to failures: {downloadClientNames}",
"DownloadClientTransmissionSettingsDirectoryHelpText": "Optional location to put downloads in, leave blank to use the default Transmission location", "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}'", "DownloadClientTransmissionSettingsUrlBaseHelpText": "Adds a prefix to the {clientName} rpc url, eg {url}, defaults to '{defaultUrl}'",
"DownloadClientUTorrentProviderMessage": "uTorrent has a history of including cryptominers, malware and ads, we strongly encourage you to choose a different client.", "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}.",
"DownloadClientUTorrentTorrentStateError": "uTorrent is reporting an error", "DownloadClientUTorrentTorrentStateError": "uTorrent is reporting an error",
"DownloadClientUnavailable": "Download Client Unavailable", "DownloadClientUnavailable": "Download Client Unavailable",
"DownloadClientValidationApiKeyIncorrect": "API Key Incorrect", "DownloadClientValidationApiKeyIncorrect": "API Key Incorrect",
@@ -656,6 +662,7 @@
"EpisodeInfo": "Episode Info", "EpisodeInfo": "Episode Info",
"EpisodeIsDownloading": "Episode is downloading", "EpisodeIsDownloading": "Episode is downloading",
"EpisodeIsNotMonitored": "Episode is not monitored", "EpisodeIsNotMonitored": "Episode is not monitored",
"EpisodeMaybePlural": "Episode(s)",
"EpisodeMissingAbsoluteNumber": "Episode does not have an absolute episode number", "EpisodeMissingAbsoluteNumber": "Episode does not have an absolute episode number",
"EpisodeMissingFromDisk": "Episode missing from disk", "EpisodeMissingFromDisk": "Episode missing from disk",
"EpisodeMonitoring": "Episode Monitoring", "EpisodeMonitoring": "Episode Monitoring",
@@ -665,6 +672,8 @@
"EpisodeRequested": "Episode Requested", "EpisodeRequested": "Episode Requested",
"EpisodeSearchResultsLoadError": "Unable to load results for this episode search. Try again later", "EpisodeSearchResultsLoadError": "Unable to load results for this episode search. Try again later",
"EpisodeTitle": "Episode Title", "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.", "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", "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", "EpisodeTitleRequiredHelpText": "Prevent importing for up to 48 hours if the episode title is in the naming format and the episode title is TBA",
@@ -1019,8 +1028,14 @@
"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.", "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", "IndexerSettingsRssUrl": "RSS URL",
"IndexerSettingsRssUrlHelpText": "Enter to URL to an {indexer} compatible RSS feed", "IndexerSettingsRssUrlHelpText": "Enter to URL to an {indexer} compatible RSS feed",
"IndexerSettingsSeasonPackSeedTime": "Season-Pack Seed Time", "IndexerSettingsSeasonPackSeedGoal": "Seeding Goal for Season Packs",
"IndexerSettingsSeasonPackSeedTimeHelpText": "The time a season-pack torrent should be seeded before stopping, empty uses the download client's default", "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",
"IndexerSettingsSeedRatio": "Seed Ratio", "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", "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", "IndexerSettingsSeedTime": "Seed Time",
@@ -1274,6 +1289,7 @@
"MultiEpisodeStyle": "Multi Episode Style", "MultiEpisodeStyle": "Multi Episode Style",
"MultiLanguages": "Multi-Languages", "MultiLanguages": "Multi-Languages",
"MultiSeason": "Multi-Season", "MultiSeason": "Multi-Season",
"MultipleEpisodes": "Multiple Episodes",
"MustContain": "Must Contain", "MustContain": "Must Contain",
"MustContainHelpText": "The release must contain at least one of these terms (case insensitive)", "MustContainHelpText": "The release must contain at least one of these terms (case insensitive)",
"MustNotContain": "Must Not Contain", "MustNotContain": "Must Not Contain",
+6 -2
View File
@@ -2151,7 +2151,6 @@
"NotificationsTelegramSettingsLinkPreviewHelpText": "Determina qué enlaces se previsualizarán en las notificaciones de Telegram. Elige 'Ninguno' para deshabilitarlo", "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", "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", "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", "NotificationsPushcutSettingsIncludePoster": "Incluir póster",
"NotificationsPushcutSettingsIncludePosterHelpText": "Incluir póster con notificación", "NotificationsPushcutSettingsIncludePosterHelpText": "Incluir póster con notificación",
"NotificationsPushcutSettingsMetadataLinks": "Enlaces de metadatos", "NotificationsPushcutSettingsMetadataLinks": "Enlaces de metadatos",
@@ -2166,5 +2165,10 @@
"NotificationsAppriseSettingsIncludePosterHelpText": "Incluir póster en el mensaje", "NotificationsAppriseSettingsIncludePosterHelpText": "Incluir póster en el mensaje",
"EpisodeMonitoring": "Monitorización de episodios", "EpisodeMonitoring": "Monitorización de episodios",
"MonitorEpisodes": "Monitorizar 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" "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)"
} }
+17 -10
View File
@@ -816,7 +816,7 @@
"CollapseMultipleEpisodesHelpText": "Tiivistä useat samana päivänä esitettävät jaksot.", "CollapseMultipleEpisodesHelpText": "Tiivistä useat samana päivänä esitettävät jaksot.",
"CalendarLegendSeriesFinaleTooltip": "Sarjan tai kauden päätösjakso", "CalendarLegendSeriesFinaleTooltip": "Sarjan tai kauden päätösjakso",
"CalendarLegendSeriesPremiereTooltip": "Sarjan tai kauden pilottijakso", "CalendarLegendSeriesPremiereTooltip": "Sarjan tai kauden pilottijakso",
"ClickToChangeSeries": "Muuta sarjaa klikkaamalla", "ClickToChangeSeries": "Vaihda sarja klikkaamalla",
"CloneIndexer": "Monista hakupalvelu", "CloneIndexer": "Monista hakupalvelu",
"Close": "Sulje", "Close": "Sulje",
"ClearBlocklist": "Tyhjennä estolista", "ClearBlocklist": "Tyhjennä estolista",
@@ -1190,7 +1190,7 @@
"AddedDate": "Lisätty: {date}", "AddedDate": "Lisätty: {date}",
"Anime": "Anime", "Anime": "Anime",
"Any": "Mikä tahansa", "Any": "Mikä tahansa",
"ClickToChangeSeason": "Vaihda tuotantokautta painamalla tästä", "ClickToChangeSeason": "Vaihda tuotantokausi klikkaamalla",
"CountSelectedFile": "{selectedCount} tiedosto on valittu", "CountSelectedFile": "{selectedCount} tiedosto on valittu",
"SingleEpisodeInvalidFormat": "Yksittäinen jakso: virheellinen kaava", "SingleEpisodeInvalidFormat": "Yksittäinen jakso: virheellinen kaava",
"Underscore": "Alaviiva", "Underscore": "Alaviiva",
@@ -1242,7 +1242,7 @@
"AuthenticationRequiredPasswordConfirmationHelpTextWarning": "Vahvista uusi salasana", "AuthenticationRequiredPasswordConfirmationHelpTextWarning": "Vahvista uusi salasana",
"Category": "Kategoria", "Category": "Kategoria",
"ChownGroup": "chown-ryhmä", "ChownGroup": "chown-ryhmä",
"ClickToChangeEpisode": "Vaihda jaksoa painamalla tästä", "ClickToChangeEpisode": "Vaihda jakso klikkaamalla",
"CompletedDownloadHandling": "Valmistuneiden latausten käsittely", "CompletedDownloadHandling": "Valmistuneiden latausten käsittely",
"Condition": "Ehto", "Condition": "Ehto",
"Continuing": "Jatkuu", "Continuing": "Jatkuu",
@@ -1834,13 +1834,13 @@
"ImportListsMyAnimeListSettingsListStatus": "Listan tila", "ImportListsMyAnimeListSettingsListStatus": "Listan tila",
"ImportListStatusAllUnavailableHealthCheckMessage": "Mitkään listat eivät ole virheiden vuoksi käytettävissä", "ImportListStatusAllUnavailableHealthCheckMessage": "Mitkään listat eivät ole virheiden vuoksi käytettävissä",
"MetadataKometaDeprecatedSetting": "Poistunut", "MetadataKometaDeprecatedSetting": "Poistunut",
"NotificationsTelegramSettingsMetadataLinksHelpText": "Lisää lähetettäviin ilmoituksiin linkit sarjojen metatietoihin.", "NotificationsTelegramSettingsMetadataLinksHelpText": "Lisää lähetettäviin ilmoituksiin linkit median metatietoihin.",
"OnFileImport": "Kun tiedosto tuodaan", "OnFileImport": "Kun tiedosto tuodaan",
"OnFileUpgrade": "Kun tiedosto päivitetään", "OnFileUpgrade": "Kun tiedosto päivitetään",
"ReleaseProfile": "Julkaisuprofiili", "ReleaseProfile": "Julkaisuprofiili",
"ShowTags": "Näytä tunnisteet", "ShowTags": "Näytä tunnisteet",
"TodayAt": "Tänään klo {time}", "TodayAt": "Tänään klo {time}",
"ClickToChangeReleaseType": "Vaihda julkaisun tyyppiä painamalla tästä", "ClickToChangeReleaseType": "Vaihda julkaisun tyyppi klikkaamalla",
"CustomFormatsSpecificationSource": "Lähde", "CustomFormatsSpecificationSource": "Lähde",
"DownloadClientQbittorrentTorrentStateMissingFiles": "qBittorrent ilmoittaa puuttuvista tiedostoista", "DownloadClientQbittorrentTorrentStateMissingFiles": "qBittorrent ilmoittaa puuttuvista tiedostoista",
"DownloadClientSabnzbdValidationCheckBeforeDownload": "Poista SABnbzd:n \"Tarkista ennen lataamista\" -asetus käytöstä", "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.", "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?", "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.", "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 sarjojen metatietoihin.", "NotificationsGotifySettingsMetadataLinksHelpText": "Lisää lähetettäviin ilmoituksiin linkit median metatietoihin.",
"NotificationsPlexSettingsServerHelpText": "Valitse tunnistautumisen jälkeen palvelin Plex.tv-tililtä.", "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.", "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.", "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,16 +2152,23 @@
"QualityDefinitionsSizeNotice": "Kokorajoitukset on siirretty laatuprofiileihin", "QualityDefinitionsSizeNotice": "Kokorajoitukset on siirretty laatuprofiileihin",
"NotificationsTelegramSettingsLinkPreview": "Linkin esikatselu", "NotificationsTelegramSettingsLinkPreview": "Linkin esikatselu",
"NotificationsTelegramSettingsLinkPreviewHelpText": "Määrittää minkä linkin esikatselu Telegram-ilmoituksessa näytetään. Poista käytöstä valitsemalla \"Ei mitään\".", "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", "NotificationsPushcutSettingsIncludePoster": "Sisällytä juliste",
"NotificationsPushcutSettingsIncludePosterHelpText": "Näytä juliste ilmoituksessa.", "NotificationsPushcutSettingsIncludePosterHelpText": "Näytä juliste ilmoituksessa.",
"NotificationsPushcutSettingsMetadataLinks": "Metatietolinkit", "NotificationsPushcutSettingsMetadataLinks": "Metatietolinkit",
"NotificationsPushcutSettingsMetadataLinksHelpText": "Lisää lähetettäviin ilmoituksiin linkit sarjojen metatietoihin.", "NotificationsPushcutSettingsMetadataLinksHelpText": "Lisää lähetettäviin ilmoituksiin linkit median metatietoihin.",
"AutoTaggingSpecificationNetwork": "Verkot", "AutoTaggingSpecificationNetwork": "Verkot",
"DownloadClientItemErrorMessage": "{clientName} ilmoittaa virheestä: {message}", "DownloadClientItemErrorMessage": "{clientName} ilmoittaa virheestä: {message}",
"EpisodesInSeason": "Tuotantokaudessa on {episodeCount} jaksoa", "EpisodesInSeason": "Tuotantokaudessa on {episodeCount} jaksoa",
"CloneImportList": "Monista tuontilista", "CloneImportList": "Monista tuontilista",
"DefaultNameCopiedImportList": "{name} (kopio)", "DefaultNameCopiedImportList": "{name} (kopio)",
"EpisodeMonitoring": "Jakson Valvonta", "EpisodeMonitoring": "Jaksojen valvonta",
"MonitorEpisodes": "Valvo Jaksoja" "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"
} }
+46 -5
View File
@@ -444,7 +444,7 @@
"NoSeriesFoundImportOrAdd": "Aucune série trouvée. Pour commencer, vous souhaiterez importer votre série existante ou ajouter une nouvelle série.", "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", "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", "SeasonFolderFormat": "Format du dossier de saison",
"QualitiesHelpText": "Les qualités plus élevées dans la liste sont plus préférées. Les qualités au sein dun même groupe sont égales. Seules les qualités vérifiées sont recherchées", "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",
"PrioritySettings": "Priorité : {priority}", "PrioritySettings": "Priorité : {priority}",
"ImportExistingSeries": "Importer une série existante", "ImportExistingSeries": "Importer une série existante",
"RootFolderSelectFreeSpace": "{freeSpace} Libre", "RootFolderSelectFreeSpace": "{freeSpace} Libre",
@@ -800,7 +800,7 @@
"UpdaterLogFiles": "Journaux du programme de mise à jour", "UpdaterLogFiles": "Journaux du programme de mise à jour",
"UpgradeUntil": "Mise à niveau jusqu'à", "UpgradeUntil": "Mise à niveau jusqu'à",
"UpgradeUntilCustomFormatScore": "Mise à niveau jusqu'au score de format personnalisé", "UpgradeUntilCustomFormatScore": "Mise à niveau jusqu'au score de format personnalisé",
"UpgradeUntilCustomFormatScoreEpisodeHelpText": "Une fois ce score de format personnalisé atteint, {appName} ne récupérera plus les sorties d'épisodes", "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",
"UrlBase": "URL de base", "UrlBase": "URL de base",
"UseHardlinksInsteadOfCopy": "Utiliser les liens durs au lieu de copier", "UseHardlinksInsteadOfCopy": "Utiliser les liens durs au lieu de copier",
"UseSeasonFolder": "Utiliser le dossier de la saison", "UseSeasonFolder": "Utiliser le dossier de la saison",
@@ -904,7 +904,7 @@
"UnmappedFilesOnly": "Fichiers non mappés uniquement", "UnmappedFilesOnly": "Fichiers non mappés uniquement",
"UnmonitorSpecialsEpisodesDescription": "Annulez la surveillance de tous les épisodes spéciaux sans modifier le statut surveillé des autres épisodes", "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} ».", "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", "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é",
"UpgradeUntilThisQualityIsMetOrExceeded": "Mise à niveau jusqu'à ce que cette qualité soit atteinte ou dépassée", "UpgradeUntilThisQualityIsMetOrExceeded": "Mise à niveau jusqu'à ce que cette qualité soit atteinte ou dépassée",
"UseProxy": "Utiliser le proxy", "UseProxy": "Utiliser le proxy",
"WaitingToImport": "En attente d'import", "WaitingToImport": "En attente d'import",
@@ -2017,7 +2017,7 @@
"ImportListsTraktSettingsPopularListTypeTrendingShows": "Spectacles en vogue", "ImportListsTraktSettingsPopularListTypeTrendingShows": "Spectacles en vogue",
"ImportListsTraktSettingsPopularName": "Liste populaire de Trakt", "ImportListsTraktSettingsPopularName": "Liste populaire de Trakt",
"ImportListsTraktSettingsRating": "Evaluation", "ImportListsTraktSettingsRating": "Evaluation",
"ImportListsTraktSettingsRatingSeriesHelpText": "Série de filtres par plage de valeurs nominales (0-100)", "ImportListsTraktSettingsRatingSeriesHelpText": "Filtrer les séries par plage de classement (0-100)",
"ImportListsTraktSettingsWatchedListFilterSeriesHelpText": "Si le type de liste est surveillé, sélectionnez le type de série que vous souhaitez importer", "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", "ImportListsTraktSettingsWatchListSorting": "Tri de la liste de surveillance",
"ImportListsTraktSettingsWatchListSortingHelpText": "Si le type de liste est surveillé, sélectionnez l'ordre de tri de la liste", "ImportListsTraktSettingsWatchListSortingHelpText": "Si le type de liste est surveillé, sélectionnez l'ordre de tri de la liste",
@@ -2123,5 +2123,46 @@
"LastSearched": "Dernière recherche", "LastSearched": "Dernière recherche",
"FolderNameTokens": "Jetons de nom de dossier", "FolderNameTokens": "Jetons de nom de dossier",
"ManageCustomFormats": "Gérer les formats personnalisés", "ManageCustomFormats": "Gérer les formats personnalisés",
"Menu": "Menu" "Menu": "Menu",
"Fallback": "Alternative",
"MetadataKometaDeprecatedSetting": "Obsolète",
"AutoTaggingSpecificationNetwork": "Réseau(x)",
"DefaultNameCopiedImportList": "{name} - Copie",
"DownloadClientItemErrorMessage": "{clientName} a rapporté une erreur : {message}",
"EditSizes": "Modifier les dimensions",
"NotificationsGotifySettingsPreferredMetadataLink": "Lien de métadonnées préféré",
"NotificationsPushcutSettingsMetadataLinksHelpText": "Ajouter un lien vers les métadonnées de la série lors de l'envoie d'une notification",
"NotificationsTelegramSettingsLinkPreviewHelpText": "Détermine quel lien sera aperçu dans la notification Telegram. Choisir 'Aucun' pour désactiver",
"DoneEditingSizes": "Terminer la modification des dimensions",
"EpisodeMonitoring": "Suivi des épisodes",
"ManageFormats": "Gérer les formats",
"MinuteShorthand": "m",
"MonitorEpisodes": "Surveiller les épisodes",
"NotificationsGotifySettingsPreferredMetadataLinkHelpText": "Lien de métadonnées pour les clients qui ne peuvent avoir qu'un seul lien",
"NotificationsSettingsWebhookHeaders": "En-têtes",
"NotificationsTelegramSettingsIncludeInstanceNameHelpText": "Inclure le nom de l'instance dans la notification de façon facultative",
"EpisodesInSeason": "{episodeCount} épisodes dans la saison",
"FileSize": "Taille de fichier",
"Maximum": "Maximum",
"Minimum": "Minimum",
"MinimumCustomFormatScoreIncrement": "Incrément minimal du score du format personnalisé",
"Minute": "minute",
"NotificationsPushcutSettingsIncludePoster": "Inclure l'affiche",
"NotificationsPushcutSettingsIncludePosterHelpText": "Inclure l'affiche avec les notifications",
"NotificationsTelegramSettingsLinkPreview": "Aperçu du lien",
"FavoriteFolderAdd": "Ajouter un dossier favori",
"FavoriteFolderRemove": "Supprimer le dossier favori",
"DownloadClientQbittorrentSettingsAddSeriesTags": "Ajouter des tags de séries",
"DownloadClientQbittorrentSettingsAddSeriesTagsHelpText": "Ajouter des tags de séries aux nouveaux torrents ajoutés au client de téléchargement (qBittorrent 4.1.0+)",
"FavoriteFolders": "Dossier favori",
"MinimumCustomFormatScoreIncrementHelpText": "Amélioration minimale requise du score de format personnalisé entre les versions existantes et nouvelles avant que {appName} ne le considère comme une mise à niveau",
"MonitorEpisodesModalInfo": "Ce paramètre n'ajustera que les épisodes ou saisons qui seront surveillés dans une série. Sélectionner Aucun retirera la surveillance de la série",
"NotificationsTelegramSettingsIncludeInstanceName": "Inclure le nom de l'instance dans le titre",
"NotificationsPushcutSettingsMetadataLinks": "Lien de métadonnées",
"UserRejectedExtensions": "Extensions de fichiers rejetées supplémentaires",
"UserRejectedExtensionsHelpText": "Liste séparée par des virgules des extensions de fichiers à échouer (“Échouer les téléchargements” doit également être activé dans lindexeur)",
"UserRejectedExtensionsTextsExamples": "Examples : '.ext, .xyz' or 'ext,xyz'",
"Warning": "Avertissement",
"QualityDefinitionsSizeNotice": "Les restrictions de taille sont maintenant dans les profils de qualité",
"UserInvokedSearch": "Recherche invoquée par lutilisateur"
} }
@@ -2152,7 +2152,6 @@
"QualityDefinitionsSizeNotice": "As restrições de tamanho foram transferidas para Perfis de Qualidade", "QualityDefinitionsSizeNotice": "As restrições de tamanho foram transferidas para Perfis de Qualidade",
"NotificationsTelegramSettingsLinkPreview": "Prévia do Link", "NotificationsTelegramSettingsLinkPreview": "Prévia do Link",
"NotificationsTelegramSettingsLinkPreviewHelpText": "Determina qual link será visualizado na notificação do Telegram. Escolha 'Nenhum' para desativar", "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", "NotificationsPushcutSettingsIncludePoster": "Incluir pôster",
"NotificationsPushcutSettingsMetadataLinks": "Links de metadados", "NotificationsPushcutSettingsMetadataLinks": "Links de metadados",
"NotificationsPushcutSettingsIncludePosterHelpText": "Incluir pôster com notificação", "NotificationsPushcutSettingsIncludePosterHelpText": "Incluir pôster com notificação",
@@ -2166,5 +2165,10 @@
"NotificationsAppriseSettingsIncludePosterHelpText": "Incluir pôster na mensagem", "NotificationsAppriseSettingsIncludePosterHelpText": "Incluir pôster na mensagem",
"EpisodeMonitoring": "Monitoramento do Episódio", "EpisodeMonitoring": "Monitoramento do Episódio",
"MonitorEpisodes": "Monitorar Episódios", "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" "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"
} }
+11 -6
View File
@@ -269,7 +269,7 @@
"AnimeEpisodeFormat": "Формат аниме-эпизода", "AnimeEpisodeFormat": "Формат аниме-эпизода",
"AuthBasic": "Базовый (Всплывающее окно браузера)", "AuthBasic": "Базовый (Всплывающее окно браузера)",
"AuthForm": "Формы (Страница авторизации)", "AuthForm": "Формы (Страница авторизации)",
"Authentication": "Авторизация", "Authentication": "Аутентификация",
"AuthenticationRequired": "Требуется авторизация", "AuthenticationRequired": "Требуется авторизация",
"BackupIntervalHelpText": "Периодичность автоматического резервного копирования", "BackupIntervalHelpText": "Периодичность автоматического резервного копирования",
"BackupRetentionHelpText": "Автоматические резервные копии старше указанного периода будут автоматически удалены", "BackupRetentionHelpText": "Автоматические резервные копии старше указанного периода будут автоматически удалены",
@@ -1500,7 +1500,7 @@
"RejectionCount": "Количество отказов", "RejectionCount": "Количество отказов",
"Release": "Релиз", "Release": "Релиз",
"ReleaseGroup": "Релиз группа", "ReleaseGroup": "Релиз группа",
"ReleaseGroupFootNote": "При необходимости можно управлять обрезкой до максимального количества байтов, включая многоточие (`...`). Поддерживается обрезка как с конца (например, `{Release Group:30}`), так и с начала (например, `{Release Group:-30}`).`).", "ReleaseGroupFootNote": "При необходимости можно управлять обрезкой до максимального количества байтов, включая многоточие (`...`). Поддерживается обрезка как с конца (например, `{Release Group:30}`), так и с начала (например, `{Release Group:-30}`).",
"ReleaseProfileIndexerHelpText": "Укажите, к какому индексатору применяется профиль", "ReleaseProfileIndexerHelpText": "Укажите, к какому индексатору применяется профиль",
"ReleaseProfileIndexerHelpTextWarning": "Установка определенного индексатора в профиле релиза приведет к тому, что этот профиль будет применяться только к релизам из этого индексатора.", "ReleaseProfileIndexerHelpTextWarning": "Установка определенного индексатора в профиле релиза приведет к тому, что этот профиль будет применяться только к релизам из этого индексатора.",
"ReleaseProfiles": "Профили релизов", "ReleaseProfiles": "Профили релизов",
@@ -1696,7 +1696,7 @@
"MetadataSettings": "Настройки метаданных", "MetadataSettings": "Настройки метаданных",
"NotificationsAppriseSettingsNotificationType": "Тип информирования об уведомлении", "NotificationsAppriseSettingsNotificationType": "Тип информирования об уведомлении",
"NotificationsEmailSettingsFromAddress": "С адреса", "NotificationsEmailSettingsFromAddress": "С адреса",
"NotificationsEmailSettingsUseEncryptionHelpText": "Выбрать режим шифрования: предпочитать шифрование, если оно настроено на сервере; всегда использовать шифрование через SSL (только порт 465) или StartTLS (любой другой порт); никогда не использовать шифрование.", "NotificationsEmailSettingsUseEncryptionHelpText": "Выбрать режим шифрования: предпочитать шифрование, если оно настроено на сервере; всегда использовать шифрование через SSL (только порт 465) или StartTLS (любой другой порт); никогда не использовать шифрование",
"NotificationsCustomScriptSettingsProviderMessage": "При тестировании будет выполняться сценарий с типом события, установленным на {eventTypeTest}. Убедитесь, что ваш сценарий обрабатывает это правильно", "NotificationsCustomScriptSettingsProviderMessage": "При тестировании будет выполняться сценарий с типом события, установленным на {eventTypeTest}. Убедитесь, что ваш сценарий обрабатывает это правильно",
"NotificationsJoinSettingsApiKeyHelpText": "Ключ API из настроек вашей учетной записи присоединения (нажмите кнопку «Присоединиться к API»).", "NotificationsJoinSettingsApiKeyHelpText": "Ключ API из настроек вашей учетной записи присоединения (нажмите кнопку «Присоединиться к API»).",
"NotificationsGotifySettingsServerHelpText": "URL-адрес сервера Gotify, включая http(s):// и порт, если необходимо", "NotificationsGotifySettingsServerHelpText": "URL-адрес сервера Gotify, включая http(s):// и порт, если необходимо",
@@ -2016,7 +2016,7 @@
"Search": "Поиск", "Search": "Поиск",
"RestartReloadNote": "Примечание: {appName} автоматически перезапустится и перезагрузит интерфейс пользователя во время процесса восстановления.", "RestartReloadNote": "Примечание: {appName} автоматически перезапустится и перезагрузит интерфейс пользователя во время процесса восстановления.",
"HealthMessagesInfoBox": "Дополнительную информацию о причине появления этих сообщений о проверке работоспособности можно найти, перейдя по ссылке wiki (значок книги) в конце строки или проверить [журналы]({link}). Если у вас возникли трудности с пониманием этих сообщений, вы можете обратиться в нашу службу поддержки по ссылкам ниже.", "HealthMessagesInfoBox": "Дополнительную информацию о причине появления этих сообщений о проверке работоспособности можно найти, перейдя по ссылке wiki (значок книги) в конце строки или проверить [журналы]({link}). Если у вас возникли трудности с пониманием этих сообщений, вы можете обратиться в нашу службу поддержки по ссылкам ниже.",
"MaintenanceRelease": "Технический релиз: исправление ошибок и другие улучшения. Подробнее см. в истории коммитов Github.", "MaintenanceRelease": "Технический релиз: исправление ошибок и другие улучшения. Подробнее см. в истории коммитов Github",
"Space": "Пробел", "Space": "Пробел",
"SslCertPasswordHelpText": "Пароль для файла pfx", "SslCertPasswordHelpText": "Пароль для файла pfx",
"SpecialEpisode": "Спец. эпизод", "SpecialEpisode": "Спец. эпизод",
@@ -2155,7 +2155,6 @@
"UpdatePath": "Обновить путь", "UpdatePath": "Обновить путь",
"UpdateSeriesPath": "Обновить путь до сериала", "UpdateSeriesPath": "Обновить путь до сериала",
"ReleasePush": "Через API", "ReleasePush": "Через API",
"DownloadClientUTorrentProviderMessage": "Мы настоятельно советуем не использовать uTorrent, т.к. он известен как программа-шифровальщик и в целом вредоносное ПО.",
"CloneImportList": "Копировать список импорта", "CloneImportList": "Копировать список импорта",
"EpisodesInSeason": "{episodeCount} эпизодов в сезоне", "EpisodesInSeason": "{episodeCount} эпизодов в сезоне",
"DefaultNameCopiedImportList": "{name} - копировать", "DefaultNameCopiedImportList": "{name} - копировать",
@@ -2165,5 +2164,11 @@
"NotificationsAppriseSettingsIncludePosterHelpText": "Добавлять постер в сообщение", "NotificationsAppriseSettingsIncludePosterHelpText": "Добавлять постер в сообщение",
"EpisodeMonitoring": "Отслеживание эпизода", "EpisodeMonitoring": "Отслеживание эпизода",
"MonitorEpisodes": "Отслеживать эпизоды", "MonitorEpisodes": "Отслеживать эпизоды",
"MonitorEpisodesModalInfo": "Эта настройка влияет только на отслеживание эпизодов или сезонов внутри сериала. Выбор ничего приведёт к остановке отслеживания сериала" "MonitorEpisodesModalInfo": "Эта настройка влияет только на отслеживание эпизодов или сезонов внутри сериала. Выбор ничего приведёт к остановке отслеживания сериала",
"ImportListsSimklSettingsUserListTypeHold": "Оставить",
"UserRejectedExtensions": "Дополнительные запрещенные расширения файлов",
"UserRejectedExtensionsTextsExamples": "Примеры: '.ext, .xyz' или 'ext,xyz'",
"DownloadClientQbittorrentSettingsAddSeriesTags": "Добавлять теги сериалов",
"DownloadClientQbittorrentSettingsAddSeriesTagsHelpText": "Добавлять теги сериалов к новым торрентам, добавляемым в загрузчик (qBittorrent 4.1.0+)",
"UserRejectedExtensionsHelpText": "Список запрещенных расширений файлов, разделенных запятой (так же нужно включить настройку Считать загрузки неуспешными в настройках индексаторов)"
} }
+2 -1
View File
@@ -43,5 +43,6 @@
"DownloadStationStatusExtracting": "Packar upp: {progress}%", "DownloadStationStatusExtracting": "Packar upp: {progress}%",
"Duplicate": "Dubblett", "Duplicate": "Dubblett",
"Yesterday": "Igår", "Yesterday": "Igår",
"EditCustomFormat": "Redigera anpassat format" "EditCustomFormat": "Redigera anpassat format",
"AbsoluteEpisodeNumber": "Fullständigt Avsnitt Nummer"
} }
+10 -3
View File
@@ -1791,7 +1791,7 @@
"IndexerValidationJackettAllNotSupportedHelpText": "Jackett'in tüm uç noktaları desteklenmiyor, lütfen indeksleyicileri tek tek ekleyin", "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.", "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}.", "IndexerValidationUnableToConnectResolutionFailure": "İndeksleyiciye bağlanılamıyor bağlantı hatası. İndeksleyicinin sunucusuna ve DNS'ine olan bağlantınızı kontrol edin. {exceptionMessage}.",
"IndexerSettingsFailDownloads": "Başarısız İndirmeler", "IndexerSettingsFailDownloads": "İndirmeleri Başarısız Say",
"IndexerSettingsFailDownloadsHelpText": "Tamamlanan indirmeler işlenirken {appName} bu seçili dosya türlerini başarısız indirmeler olarak değerlendirecektir.", "IndexerSettingsFailDownloadsHelpText": "Tamamlanan indirmeler işlenirken {appName} bu seçili dosya türlerini başarısız indirmeler olarak değerlendirecektir.",
"IndexerSettingsMinimumSeeders": "Minimum Seeder", "IndexerSettingsMinimumSeeders": "Minimum Seeder",
"IndexerSettingsRssUrl": "RSS URL", "IndexerSettingsRssUrl": "RSS URL",
@@ -2152,7 +2152,6 @@
"QualityDefinitionsSizeNotice": "Boyut kısıtlamaları Kalite Profillerine taşındı", "QualityDefinitionsSizeNotice": "Boyut kısıtlamaları Kalite Profillerine taşındı",
"NotificationsTelegramSettingsLinkPreview": "Bağlantı Önizlemesi", "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", "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", "NotificationsPushcutSettingsIncludePoster": "Posteri Dahil Et",
"NotificationsPushcutSettingsMetadataLinks": "Meta Veri Bağlantıları", "NotificationsPushcutSettingsMetadataLinks": "Meta Veri Bağlantıları",
"NotificationsPushcutSettingsMetadataLinksHelpText": "Bildirim içeriğine meta verilerin bağlantılarını ekleyin", "NotificationsPushcutSettingsMetadataLinksHelpText": "Bildirim içeriğine meta verilerin bağlantılarını ekleyin",
@@ -2163,5 +2162,13 @@
"EpisodesInSeason": "Sezondaki {episodeCount} bölüm", "EpisodesInSeason": "Sezondaki {episodeCount} bölüm",
"AutoTaggingSpecificationNetwork": "Ağ(lar)", "AutoTaggingSpecificationNetwork": "Ağ(lar)",
"NotificationsAppriseSettingsIncludePoster": "Poster'i ekle", "NotificationsAppriseSettingsIncludePoster": "Poster'i ekle",
"NotificationsAppriseSettingsIncludePosterHelpText": "Mesaja poster 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"
} }
@@ -2123,7 +2123,6 @@
"Airs": "Ефіри", "Airs": "Ефіри",
"DoneEditingSizes": "Редагування розмірів завершено", "DoneEditingSizes": "Редагування розмірів завершено",
"IndexerSettingsFailDownloads": "Не вдалося завантажити", "IndexerSettingsFailDownloads": "Не вдалося завантажити",
"DownloadClientUTorrentProviderMessage": "uTorrent має історію включення криптомайнерів, шкідливого програмного забезпечення та реклами. Ми наполегливо рекомендуємо вибрати інший клієнт.",
"EditSelectedCustomFormats": "Змінити вибрані власні формати", "EditSelectedCustomFormats": "Змінити вибрані власні формати",
"EditSizes": "Змінити розміри", "EditSizes": "Змінити розміри",
"FailedToFetchSettings": "Не вдалося отримати налаштування", "FailedToFetchSettings": "Не вдалося отримати налаштування",
@@ -156,7 +156,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual
var downloadClientItem = GetTrackedDownload(downloadId)?.DownloadItem; var downloadClientItem = GetTrackedDownload(downloadId)?.DownloadItem;
var episodes = _episodeService.GetEpisodes(episodeIds); var episodes = _episodeService.GetEpisodes(episodeIds);
var finalReleaseGroup = releaseGroup.IsNullOrWhiteSpace() var finalReleaseGroup = releaseGroup.IsNullOrWhiteSpace()
? Parser.Parser.ParseReleaseGroup(path) ? Parser.ReleaseGroupParser.ParseReleaseGroup(path)
: releaseGroup; : releaseGroup;
var finalQuality = quality.Quality == Quality.Unknown ? QualityParser.ParseQuality(path) : quality; var finalQuality = quality.Quality == Quality.Unknown ? QualityParser.ParseQuality(path) : quality;
var finalLanguges = var finalLanguges =
@@ -218,7 +218,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual
SceneSource = SceneSource(series, rootFolder), SceneSource = SceneSource(series, rootFolder),
ExistingFile = series.Path.IsParentPath(path), ExistingFile = series.Path.IsParentPath(path),
Size = _diskProvider.GetFileSize(path), Size = _diskProvider.GetFileSize(path),
ReleaseGroup = releaseGroup.IsNullOrWhiteSpace() ? Parser.Parser.ParseReleaseGroup(path) : releaseGroup, ReleaseGroup = releaseGroup.IsNullOrWhiteSpace() ? Parser.ReleaseGroupParser.ParseReleaseGroup(path) : releaseGroup,
Languages = languages?.Count <= 1 && (languages?.SingleOrDefault() ?? Language.Unknown) == Language.Unknown ? LanguageParser.ParseLanguages(path) : languages, Languages = languages?.Count <= 1 && (languages?.SingleOrDefault() ?? Language.Unknown) == Language.Unknown ? LanguageParser.ParseLanguages(path) : languages,
Quality = quality.Quality == Quality.Unknown ? QualityParser.ParseQuality(path) : quality, Quality = quality.Quality == Quality.Unknown ? QualityParser.ParseQuality(path) : quality,
IndexerFlags = (IndexerFlags)indexerFlags, IndexerFlags = (IndexerFlags)indexerFlags,
@@ -331,7 +331,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual
{ {
var localEpisode = new LocalEpisode(); var localEpisode = new LocalEpisode();
localEpisode.Path = file; localEpisode.Path = file;
localEpisode.ReleaseGroup = Parser.Parser.ParseReleaseGroup(file); localEpisode.ReleaseGroup = Parser.ReleaseGroupParser.ParseReleaseGroup(file);
localEpisode.Quality = QualityParser.ParseQuality(file); localEpisode.Quality = QualityParser.ParseQuality(file);
localEpisode.Languages = LanguageParser.ParseLanguages(file); localEpisode.Languages = LanguageParser.ParseLanguages(file);
localEpisode.Size = _diskProvider.GetFileSize(file); localEpisode.Size = _diskProvider.GetFileSize(file);
@@ -14,7 +14,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport
if (!otherVideoFiles && downloadClientInfo != null && !downloadClientInfo.FullSeason) if (!otherVideoFiles && downloadClientInfo != null && !downloadClientInfo.FullSeason)
{ {
return Parser.Parser.RemoveFileExtension(downloadClientInfo.ReleaseTitle); return FileExtensions.RemoveFileExtension(downloadClientInfo.ReleaseTitle);
} }
var fileName = Path.GetFileNameWithoutExtension(localEpisode.Path.CleanFilePath()); var fileName = Path.GetFileNameWithoutExtension(localEpisode.Path.CleanFilePath());
+28 -8
View File
@@ -1,11 +1,21 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace NzbDrone.Core.MediaFiles namespace NzbDrone.Core.MediaFiles
{ {
public static class FileExtensions public static class FileExtensions
{ {
private static List<string> _archiveExtensions = new List<string> private static readonly Regex FileExtensionRegex = new(@"\.[a-z0-9]{2,4}$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly HashSet<string> UsenetExtensions = new HashSet<string>()
{
".par2",
".nzb"
};
public static HashSet<string> ArchiveExtensions => new(StringComparer.OrdinalIgnoreCase)
{ {
".7z", ".7z",
".bz2", ".bz2",
@@ -20,8 +30,7 @@ namespace NzbDrone.Core.MediaFiles
".tgz", ".tgz",
".zip" ".zip"
}; };
public static HashSet<string> DangerousExtensions => new(StringComparer.OrdinalIgnoreCase)
private static List<string> _dangerousExtensions = new List<string>
{ {
".arj", ".arj",
".lnk", ".lnk",
@@ -31,8 +40,7 @@ namespace NzbDrone.Core.MediaFiles
".vbs", ".vbs",
".zipx" ".zipx"
}; };
public static HashSet<string> ExecutableExtensions => new(StringComparer.OrdinalIgnoreCase)
private static List<string> _executableExtensions = new List<string>
{ {
".bat", ".bat",
".cmd", ".cmd",
@@ -40,8 +48,20 @@ namespace NzbDrone.Core.MediaFiles
".sh" ".sh"
}; };
public static HashSet<string> ArchiveExtensions => new HashSet<string>(_archiveExtensions, StringComparer.OrdinalIgnoreCase); public static string RemoveFileExtension(string title)
public static HashSet<string> DangerousExtensions => new HashSet<string>(_dangerousExtensions, StringComparer.OrdinalIgnoreCase); {
public static HashSet<string> ExecutableExtensions => new HashSet<string>(_executableExtensions, StringComparer.OrdinalIgnoreCase); title = FileExtensionRegex.Replace(title, m =>
{
var extension = m.Value.ToLower();
if (MediaFileExtensions.Extensions.Contains(extension) || UsenetExtensions.Contains(extension))
{
return string.Empty;
}
return m.Value;
});
return title;
}
} }
} }

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