1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-16 21:15:28 -04:00

Compare commits

..

77 Commits

Author SHA1 Message Date
Louis R
060b789bc6 Fixed: Exceptions when checking for routable IPv4 addresses 2024-03-28 01:31:28 -04:00
Bogdan
7353fe479d New: Allow HEAD requests to ping endpoint
Closes #6656
2024-03-28 01:30:45 -04:00
Alex Cortelyou
1ec1ce58e9 New: Add additional fields to Webhook Manual Interaction Required events 2024-03-28 01:30:21 -04:00
Stevie Robinson
35d0e6a6f8 Fixed: Handling torrents with relative path in rTorrent 2024-03-28 01:29:15 -04:00
Carlos Gustavo Sarmiento
588372fd95 Fixed: qBittorrent not correctly handling retention during testing 2024-03-28 01:28:41 -04:00
Bogdan
13c925b341 New: Advanced settings toggle in import list, notification and download client modals 2024-03-27 22:27:51 -07:00
iceypotato
1335efd487 New: My Anime List import list
Closes #5148
2024-03-27 22:27:34 -07:00
Mark McDowall
d338425951 Fixed: Use custom formats from import during rename 2024-03-27 22:27:25 -07:00
Mark McDowall
fc6494c569 Fixed: Task with removed series causing error 2024-03-27 22:27:14 -07:00
Weblate
c403b2cdd5 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Altair <villagermd@outlook.com>
Co-authored-by: Dani Talens <databio@gmail.com>
Co-authored-by: Fixer <ygj59783@zslsz.com>
Co-authored-by: Stanislav <prekop3@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@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/sk/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2024-03-27 22:27:07 -07:00
Weblate
cf3d51bab2 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Casselluu <jack10193@163.com>
Co-authored-by: Gianmarco Novelli <rinogaetano94@live.it>
Co-authored-by: Jason54 <jason54700.jg@gmail.com>
Co-authored-by: MadaxDeLuXe <madaxdeluxe@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: infoaitek24 <info@aitekph.com>
Co-authored-by: reloxx <reloxx@interia.pl>
Co-authored-by: shimmyx <shimmygodx@gmail.com>
Co-authored-by: vfaergestad <vgf@hotmail.no>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nb_NO/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2024-03-21 21:21:30 -07:00
Mark McDowall
dec3fc6889 Fixed: Don't add series from import list with no matched TVDB ID 2024-03-22 00:21:04 -04:00
Mark McDowall
40bac23698 New: Support parsing season number from season folder when importing
Closes #903
2024-03-21 21:20:49 -07:00
Mark McDowall
88de927435 Fixed: Plex Watchlist import list 2024-03-21 21:20:27 -07:00
Mark McDowall
29204c93a3 New: Parsing multi-episode file with two and three digit episode numbers
Closes #6631
2024-03-21 21:20:13 -07:00
Mark McDowall
c641733781 Fixed: Task progress messages in the UI
Closes #6632
2024-03-21 21:20:08 -07:00
Weblate
58de0310fd Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Gianmarco Novelli <rinogaetano94@live.it>
Co-authored-by: Jason54 <jason54700.jg@gmail.com>
Co-authored-by: MadaxDeLuXe <madaxdeluxe@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: infoaitek24 <info@aitekph.com>
Co-authored-by: reloxx <reloxx@interia.pl>
Co-authored-by: vfaergestad <vgf@hotmail.no>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nb_NO/
Translation: Servarr/Sonarr
2024-03-21 21:20:01 -07:00
Bogdan
172b1a82d1 Sort series by title in task name 2024-03-21 21:19:23 -07:00
Bogdan
e14568adef Ensure not allowed cursor is shown for disabled select inputs 2024-03-21 21:19:23 -07:00
Weblate
381ce61aef Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Dennis Langthjem <dennis@langthjem.dk>
Co-authored-by: DimitriDR <dimitridroeck@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Ihor Mudryi <mudryy33@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/da/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
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/uk/
Translation: Servarr/Sonarr
2024-03-13 21:49:22 -07:00
Mark McDowall
9f705e4161 Fixed: Release push with only Magnet URL
Closes #6622
2024-03-13 21:47:50 -07:00
Mark McDowall
063dba22a8 Fixed: Disabled select option still selectable 2024-03-13 21:47:33 -07:00
Mark McDowall
6d552f2a60 New: Show Series title and season number after task name when applicable
Closes #6601
2024-03-13 21:47:22 -07:00
Mark McDowall
4d4d63921b Add notification for build success/failures 2024-03-14 00:47:01 -04:00
Alan Collins
6584d95331 New: Update Custom Format renaming token to allow excluding specific formats
Closes #6615
2024-03-14 00:46:33 -04:00
Bogdan
86034beccd Bump ImageSharp, Polly, DryIoc, STJson, WindowsServices 2024-03-13 21:44:23 -07:00
Mark McDowall
4aa56e3f91 Fixed: Parsing of some French and Spanish anime releases 2024-03-13 21:44:07 -07:00
Stevie Robinson
2ec071a5ec Update release profile download client warning 2024-03-09 23:54:21 -05:00
Alan Collins
d86aeb7472 New: Release Hash renaming token
Closes #6570
2024-03-09 23:54:06 -05:00
Alan Collins
48cb5d2271 New: 'Custom Format: Format Name' rename token 2024-03-09 23:53:02 -05:00
bakerboy448
a0329adeba Improve single file detected as full season messaging 2024-03-09 23:51:29 -05:00
Bogdan
89bef4af99 New: Wider modal for Interactive Search and Manual Import 2024-03-09 23:50:45 -05:00
Mark McDowall
a12cdb34bc Fixed: Error sending Manual Interaction Required notification 2024-03-07 18:11:36 -08:00
Bogdan
13e29bd257 Prevent NullRef in naming when truncating a null Release Group 2024-03-07 18:11:28 -08:00
Sonarr
61a7515041 Automated API Docs update
ignore-downstream
2024-03-07 17:34:04 -08:00
Weblate
2c25245860 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Jason54 <jason54700.jg@gmail.com>
Co-authored-by: Mark Martines <mark-martines@hotmail.com>
Co-authored-by: Maxence Winandy <maxence.winandy@gmail.com>
Co-authored-by: Stevie Robinson <stevie.robinson@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: linkin931 <931linkin@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/el/
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/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2024-03-07 17:30:56 -08:00
Bogdan
18aadb544e Fixed: Maintain release type for items in Manual Import 2024-03-07 20:30:20 -05:00
Helvio Pedreschi
c7dd7abf89 Fixed: WebApp functionality on Apple devices 2024-03-07 20:29:50 -05:00
CheAle14
d0e9504af0 Fix import list exclusion props 2024-03-07 17:26:29 -08:00
Bogdan
e81bb3b993 Persist page size for Import List Exclusions 2024-03-07 17:25:27 -08:00
Bogdan
f211433b77 Remove debugger from metadata source and rearrange some imports 2024-03-07 17:25:19 -08:00
Bogdan
2068c5393e Fixed: URL Base setting for Kodi connections 2024-03-07 17:25:19 -08:00
Mark McDowall
0183812cc5 Fixed: Overly aggressive exception release group parsing
Closes #6591
2024-03-07 17:25:10 -08:00
Bogdan
7f09903a06 New: Episode Requested filter for Interactive Search 2024-03-02 21:26:23 -08:00
Mark McDowall
fa4c11a943 New: Do not automatically unmonitor episodes renamed outside of Sonarr
Closes #6584
2024-03-02 21:23:27 -08:00
Sonarr
653963a247 Automated API Docs update
ignore-downstream
2024-03-02 21:22:27 -08:00
Mark McDowall
32c32e2f88 Fixed: Issue extracting subtitle information for unknown episodes 2024-03-02 21:22:15 -08:00
nopoz
07bd159436 New: Add download directory & move completed for Deluge
Closes #4575
2024-03-03 00:22:03 -05:00
Mark McDowall
20273b07ad Properly type validation errors/warnings 2024-03-02 21:21:24 -08:00
bakerboy448
e5f19f01fa Update AddSeries Messaging and Logging 2024-03-03 00:21:16 -05:00
Louis R
13af6f5779 Fixed: Don't disable IPv6 in IPv6-only Environment
Closes #6545
2024-03-03 00:20:36 -05:00
Mark McDowall
71c2c0570b Renamed SeasonPackSpecification to ReleaseTypeSpecification 2024-03-03 00:19:44 -05:00
Mark McDowall
64c6a8879b Queue Manual Import commands at high priority 2024-03-02 21:19:26 -08:00
Weblate
7f061a9583 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Fixer <ygj59783@zslsz.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Nicolò Castagnola <nipica@outlook.it>
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/de/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2024-03-02 21:19:17 -08:00
The Dark
4285691064 New: Import list exclusion pagination
Closes #6079
2024-03-03 00:19:02 -05:00
Sonarr
de9899c60e Automated API Docs update
ignore-downstream
2024-03-01 17:33:41 -08:00
Weblate
6c8758c27a Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translation: Servarr/Sonarr
2024-03-01 17:32:55 -08:00
Mark McDowall
086d3b5afa Increase migration timeout to 5 minutes 2024-03-01 17:26:26 -08:00
Mark McDowall
f8a0751775 New: Release Type (Single/Multi episode and Season Pack) for Custom Formats
Closes #3562
2024-03-01 17:26:26 -08:00
Mark McDowall
c99d81e79b New: Bypass archived history for failed downloads in SABnzbd 2024-03-01 17:26:10 -08:00
Mark McDowall
9fd193d2a8 New: URL Base setting for Media Server connections
Closes #4416
2024-03-01 17:24:52 -08:00
Bogdan
64f4365fe9 Update caniuse-lite 2024-03-01 17:24:41 -08:00
Bogdan
2773f77e1c New: Options button for Missing/Cutoff Unmet 2024-03-01 17:24:41 -08:00
Weblate
0a84b4a8e9 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translation: Servarr/Sonarr
2024-03-01 17:24:35 -08:00
Weblate
236d8e4c50 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translation: Servarr/Sonarr
2024-02-27 20:44:11 -08:00
Mark McDowall
16d3827dbd Fixed: Processing updated episodes in series after refresh
Closes #6560
2024-02-27 20:42:27 -08:00
Bogdan
fa600e62e0 Fixed: Error when download client information is unavailable for Manual Interaction Required event
Closes #6558
(cherry picked from commit 173b1d6a4c0f2125c4413c0c09b269d87a1f1ee8)

Co-authored-by: Qstick <qstick@gmail.com>
2024-02-27 23:42:08 -05:00
Mark McDowall
fb6fc568c5 Fixed: Don't store seasons from import list items in database
Closes #6555
2024-02-27 20:40:39 -08:00
Bogdan
1f97679868 Fixed: Selection of last added custom filter
Plus some translations and typos
2024-02-27 20:40:33 -08:00
Mark McDowall
b34e0f8259 Fixed: Ignore language in split episode title 2024-02-27 00:33:07 -05:00
Mark McDowall
4c170d0452 New: Update anime episodes by season/episode number instead of absolute episode number
Closes #6547
2024-02-26 21:32:46 -08:00
Mark McDowall
6dc0a88004 New: Search for recently aired anime episodes with added absolute episode number
Closes #2044
2024-02-26 21:32:46 -08:00
Mark McDowall
33b44a8a53 New: Option to sync season monitoring state when importing from another Sonarr instance
Closes #6542
2024-02-26 21:32:39 -08:00
Mark McDowall
cb72e752f9 Fixed: Parsing of subtitle languages separated by dash
Closes #6494
2024-02-27 00:32:31 -05:00
Weblate
a11ee7bc11 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: EDUYO <eduardoestabiel@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Magyar <kochnorbert@icloud.com>
Co-authored-by: Sadi A. Nogueira <contato@sadi.eti.br>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Xupix <colinaubert25@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translation: Servarr/Sonarr
2024-02-26 21:32:04 -08:00
Stevie Robinson
98d60e1a8e Replace URLs in translations with tokens 2024-02-27 00:30:58 -05:00
Bruno Garcia
6377c688fc Update Sentry SDK add features
Co-authored-by: Stefan Jandl <reg@bitfox.at>
2024-02-27 00:30:32 -05:00
219 changed files with 5084 additions and 2019 deletions

View File

@@ -121,7 +121,7 @@ jobs:
run: yarn lint
- name: Stylelint
run: yarn stylelint
run: yarn stylelint -f github
- name: Build
run: yarn build --env production
@@ -225,3 +225,25 @@ jobs:
branch: ${{ github.ref_name }}
major_version: ${{ needs.backend.outputs.major_version }}
version: ${{ needs.backend.outputs.version }}
notify:
name: Discord Notification
needs: [backend, unit_test, unit_test_postgres, integration_test]
if: ${{ !cancelled() && (github.ref_name == 'develop' || github.ref_name == 'main') }}
env:
STATUS: ${{ contains(needs.*.result, 'failure') && 'failure' || 'success' }}
runs-on: ubuntu-latest
steps:
- name: Notify
uses: tsickert/discord-webhook@v5.3.0
with:
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
username: 'GitHub Actions'
avatar-url: 'https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png'
embed-title: "${{ github.workflow }}: ${{ env.STATUS == 'success' && 'Success' || 'Failure' }}"
embed-url: 'https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}'
embed-description: |
**Branch** ${{ github.ref }}
**Build** ${{ needs.backend.outputs.version }}
embed-color: ${{ env.STATUS == 'success' && '3066993' || '15158332' }}

View File

@@ -19,6 +19,7 @@ export interface AppSectionSaveState {
export interface PagedAppSectionState {
pageSize: number;
totalRecords?: number;
}
export interface AppSectionFilterState<T> {
@@ -38,6 +39,7 @@ export interface AppSectionItemState<T> {
isFetching: boolean;
isPopulated: boolean;
error: Error;
pendingChanges: Partial<T>;
item: T;
}

View File

@@ -3,10 +3,12 @@ import AppSectionState, {
AppSectionItemState,
AppSectionSaveState,
AppSectionSchemaState,
PagedAppSectionState,
} from 'App/State/AppSectionState';
import Language from 'Language/Language';
import DownloadClient from 'typings/DownloadClient';
import ImportList from 'typings/ImportList';
import ImportListExclusion from 'typings/ImportListExclusion';
import ImportListOptionsSettings from 'typings/ImportListOptionsSettings';
import Indexer from 'typings/Indexer';
import IndexerFlag from 'typings/IndexerFlag';
@@ -41,6 +43,14 @@ export interface ImportListOptionsSettingsAppState
extends AppSectionItemState<ImportListOptionsSettings>,
AppSectionSaveState {}
export interface ImportListExclusionsSettingsAppState
extends AppSectionState<ImportListExclusion>,
AppSectionSaveState,
PagedAppSectionState,
AppSectionDeleteState {
pendingChanges: Partial<ImportListExclusion>;
}
export type IndexerFlagSettingsAppState = AppSectionState<IndexerFlag>;
export type LanguageSettingsAppState = AppSectionState<Language>;
export type UiSettingsAppState = AppSectionItemState<UiSettings>;
@@ -48,6 +58,7 @@ export type UiSettingsAppState = AppSectionItemState<UiSettings>;
interface SettingsAppState {
advancedSettings: boolean;
downloadClients: DownloadClientAppState;
importListExclusions: ImportListExclusionsSettingsAppState;
importListOptions: ImportListOptionsSettingsAppState;
importLists: ImportListAppState;
indexerFlags: IndexerFlagSettingsAppState;

View File

@@ -13,6 +13,8 @@ export interface CommandBody {
trigger: string;
suppressMessages: boolean;
seriesId?: number;
seriesIds?: number[];
seasonNumber?: number;
}
interface Command extends ModelBase {

View File

@@ -117,7 +117,7 @@ class FileBrowserModalContent extends Component {
className={styles.mappedDrivesWarning}
kind={kinds.WARNING}
>
<InlineMarkdown data={translate('MappedNetworkDrivesWindowsService')} />
<InlineMarkdown data={translate('MappedNetworkDrivesWindowsService', { url: 'https://wiki.servarr.com/sonarr/faq#why-cant-sonarr-see-my-files-on-a-remote-server' })} />
</Alert>
}

View File

@@ -1,3 +1,4 @@
import { maxBy } from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FormInputGroup from 'Components/Form/FormInputGroup';
@@ -8,6 +9,7 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import FilterBuilderRow from './FilterBuilderRow';
import styles from './FilterBuilderModalContent.css';
@@ -49,7 +51,7 @@ class FilterBuilderModalContent extends Component {
if (id) {
dispatchSetFilter({ selectedFilterKey: id });
} else {
const last = customFilters[customFilters.length -1];
const last = maxBy(customFilters, 'id');
dispatchSetFilter({ selectedFilterKey: last.id });
}
@@ -107,7 +109,7 @@ class FilterBuilderModalContent extends Component {
this.setState({
labelErrors: [
{
message: 'Label is required'
message: translate('LabelIsRequired')
}
]
});
@@ -145,13 +147,13 @@ class FilterBuilderModalContent extends Component {
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Custom Filter
{translate('CustomFilter')}
</ModalHeader>
<ModalBody>
<div className={styles.labelContainer}>
<div className={styles.label}>
Label
{translate('Label')}
</div>
<div className={styles.labelInputContainer}>
@@ -165,7 +167,9 @@ class FilterBuilderModalContent extends Component {
</div>
</div>
<div className={styles.label}>Filters</div>
<div className={styles.label}>
{translate('Filters')}
</div>
<div className={styles.rows}>
{
@@ -192,7 +196,7 @@ class FilterBuilderModalContent extends Component {
<ModalFooter>
<Button onPress={onCancelPress}>
Cancel
{translate('Cancel')}
</Button>
<SpinnerErrorButton
@@ -200,7 +204,7 @@ class FilterBuilderModalContent extends Component {
error={saveError}
onPress={this.onSaveFilterPress}
>
Save
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>

View File

@@ -37,8 +37,8 @@ class CustomFilter extends Component {
dispatchSetFilter
} = this.props;
// Assume that delete and then unmounting means the delete was successful.
// Moving this check to a ancestor would be more accurate, but would have
// Assume that delete and then unmounting means the deletion was successful.
// Moving this check to an ancestor would be more accurate, but would have
// more boilerplate.
if (this.state.isDeleting && id === selectedFilterKey) {
dispatchSetFilter({ selectedFilterKey: 'all' });

View File

@@ -19,7 +19,7 @@
.isDisabled {
opacity: 0.7;
cursor: not-allowed;
cursor: not-allowed !important;
}
.dropdownArrowContainer {

View File

@@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react';
import monitorOptions from 'Utilities/Series/monitorOptions';
import translate from 'Utilities/String/translate';
import SelectInput from './SelectInput';
import EnhancedSelectInput from './EnhancedSelectInput';
function MonitorEpisodesSelectInput(props) {
const {
@@ -19,7 +19,7 @@ function MonitorEpisodesSelectInput(props) {
get value() {
return translate('NoChange');
},
disabled: true
isDisabled: true
});
}
@@ -29,12 +29,12 @@ function MonitorEpisodesSelectInput(props) {
get value() {
return `(${translate('Mixed')})`;
},
disabled: true
isDisabled: true
});
}
return (
<SelectInput
<EnhancedSelectInput
values={values}
{...otherProps}
/>

View File

@@ -1,7 +1,7 @@
import PropTypes from 'prop-types';
import React from 'react';
import monitorNewItemsOptions from 'Utilities/Series/monitorNewItemsOptions';
import SelectInput from './SelectInput';
import EnhancedSelectInput from './EnhancedSelectInput';
function MonitorNewItemsSelectInput(props) {
const {
@@ -16,7 +16,7 @@ function MonitorNewItemsSelectInput(props) {
values.unshift({
key: 'noChange',
value: 'No Change',
disabled: true
isDisabled: true
});
}
@@ -24,12 +24,12 @@ function MonitorNewItemsSelectInput(props) {
values.unshift({
key: 'mixed',
value: '(Mixed)',
disabled: true
isDisabled: true
});
}
return (
<SelectInput
<EnhancedSelectInput
values={values}
{...otherProps}
/>

View File

@@ -28,7 +28,7 @@ function createMapStateToProps() {
get value() {
return translate('NoChange');
},
disabled: includeNoChangeDisabled
isDisabled: includeNoChangeDisabled
});
}
@@ -38,7 +38,7 @@ function createMapStateToProps() {
get value() {
return `(${translate('Mixed')})`;
},
disabled: true
isDisabled: true
});
}

View File

@@ -15,7 +15,7 @@ interface ISeriesTypeOption {
key: string;
value: string;
format?: string;
disabled?: boolean;
isDisabled?: boolean;
}
const seriesTypeOptions: ISeriesTypeOption[] = [
@@ -55,7 +55,7 @@ function SeriesTypeSelectInput(props: SeriesTypeSelectInputProps) {
values.unshift({
key: 'noChange',
value: translate('NoChange'),
disabled: includeNoChangeDisabled,
isDisabled: includeNoChangeDisabled,
});
}
@@ -63,7 +63,7 @@ function SeriesTypeSelectInput(props: SeriesTypeSelectInputProps) {
values.unshift({
key: 'mixed',
value: `(${translate('Mixed')})`,
disabled: true,
isDisabled: true,
});
}

View File

@@ -63,6 +63,13 @@
width: 1280px;
}
.extraExtraLarge {
composes: modal;
width: 1600px;
}
@media only screen and (max-width: $breakpointExtraLarge) {
.modal.extraLarge {
width: 90%;
@@ -90,7 +97,8 @@
.modal.small,
.modal.medium,
.modal.large,
.modal.extraLarge {
.modal.extraLarge,
.modal.extraExtraLarge {
max-height: 100%;
width: 100%;
height: 100% !important;

View File

@@ -1,6 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'extraExtraLarge': string;
'extraLarge': string;
'large': string;
'medium': string;

View File

@@ -37,7 +37,7 @@ class EpisodeDetailsModal extends Component {
return (
<Modal
isOpen={isOpen}
size={sizes.EXTRA_LARGE}
size={sizes.EXTRA_EXTRA_LARGE}
closeOnBackgroundClick={this.state.closeOnBackgroundClick}
onModalClose={onModalClose}
>

View File

@@ -0,0 +1,17 @@
import { useCallback, useState } from 'react';
export default function useModalOpenState(
initialState: boolean
): [boolean, () => void, () => void] {
const [isOpen, setOpen] = useState(initialState);
const setModalOpen = useCallback(() => {
setOpen(true);
}, [setOpen]);
const setModalClosed = useCallback(() => {
setOpen(false);
}, [setOpen]);
return [isOpen, setModalOpen, setModalClosed];
}

View File

@@ -3,5 +3,5 @@ export const SMALL = 'small';
export const MEDIUM = 'medium';
export const LARGE = 'large';
export const EXTRA_LARGE = 'extraLarge';
export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE];
export const EXTRA_EXTRA_LARGE = 'extraExtraLarge';
export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE, EXTRA_EXTRA_LARGE];

View File

@@ -1,5 +1,6 @@
import ModelBase from 'App/ModelBase';
import Episode from 'Episode/Episode';
import ReleaseType from 'InteractiveImport/ReleaseType';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import Series from 'Series/Series';
@@ -33,6 +34,7 @@ interface InteractiveImport extends ModelBase {
qualityWeight: number;
customFormats: object[];
indexerFlags: number;
releaseType: ReleaseType;
rejections: Rejection[];
episodeFileId?: number;
}

View File

@@ -47,7 +47,7 @@ function InteractiveImportModal(props: InteractiveImportModalProps) {
return (
<Modal
isOpen={isOpen}
size={sizes.EXTRA_LARGE}
size={sizes.EXTRA_EXTRA_LARGE}
closeOnBackgroundClick={false}
onModalClose={onModalClose}
>

View File

@@ -0,0 +1,3 @@
type ReleaseType = 'unknown' | 'singleEpisode' | 'multiEpisode' | 'seasonPack';
export default ReleaseType;

View File

@@ -36,7 +36,7 @@ const monitoredOptions = [
get value() {
return translate('NoChange');
},
disabled: true,
isDisabled: true,
},
{
key: 'monitored',
@@ -58,7 +58,7 @@ const seasonFolderOptions = [
get value() {
return translate('NoChange');
},
disabled: true,
isDisabled: true,
},
{
key: 'yes',

View File

@@ -15,7 +15,7 @@ function SeasonInteractiveSearchModal(props) {
return (
<Modal
isOpen={isOpen}
size={sizes.EXTRA_LARGE}
size={sizes.EXTRA_EXTRA_LARGE}
closeOnBackgroundClick={false}
onModalClose={onModalClose}
>

View File

@@ -55,10 +55,10 @@ function EditSpecificationModalContent(props) {
<InlineMarkdown data={translate('ConditionUsingRegularExpressions')} />
</div>
<div>
<InlineMarkdown data={translate('RegularExpressionsTutorialLink')} />
<InlineMarkdown data={translate('RegularExpressionsTutorialLink', { url: 'https://www.regular-expressions.info/tutorial.html' })} />
</div>
<div>
<InlineMarkdown data={translate('RegularExpressionsCanBeTested')} />
<InlineMarkdown data={translate('RegularExpressionsCanBeTested', { url: 'http://regexstorm.net/tester' })} />
</div>
</Alert>
}

View File

@@ -15,6 +15,7 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton';
import translate from 'Utilities/String/translate';
import styles from './EditDownloadClientModalContent.css';
@@ -37,6 +38,7 @@ class EditDownloadClientModalContent extends Component {
onModalClose,
onSavePress,
onTestPress,
onAdvancedSettingsPress,
onDeleteDownloadClientPress,
...otherProps
} = this.props;
@@ -199,6 +201,12 @@ class EditDownloadClientModalContent extends Component {
</Button>
}
<AdvancedSettingsButton
advancedSettings={advancedSettings}
onAdvancedSettingsPress={onAdvancedSettingsPress}
showLabel={false}
/>
<SpinnerErrorButton
isSpinning={isTesting}
error={saveError}
@@ -239,6 +247,7 @@ EditDownloadClientModalContent.propTypes = {
onModalClose: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onTestPress: PropTypes.func.isRequired,
onAdvancedSettingsPress: PropTypes.func.isRequired,
onDeleteDownloadClientPress: PropTypes.func
};

View File

@@ -2,7 +2,13 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { saveDownloadClient, setDownloadClientFieldValue, setDownloadClientValue, testDownloadClient } from 'Store/Actions/settingsActions';
import {
saveDownloadClient,
setDownloadClientFieldValue,
setDownloadClientValue,
testDownloadClient,
toggleAdvancedSettings
} from 'Store/Actions/settingsActions';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import EditDownloadClientModalContent from './EditDownloadClientModalContent';
@@ -23,7 +29,8 @@ const mapDispatchToProps = {
setDownloadClientValue,
setDownloadClientFieldValue,
saveDownloadClient,
testDownloadClient
testDownloadClient,
toggleAdvancedSettings
};
class EditDownloadClientModalContentConnector extends Component {
@@ -56,6 +63,10 @@ class EditDownloadClientModalContentConnector extends Component {
this.props.testDownloadClient({ id: this.props.id });
};
onAdvancedSettingsPress = () => {
this.props.toggleAdvancedSettings();
};
//
// Render
@@ -65,6 +76,7 @@ class EditDownloadClientModalContentConnector extends Component {
{...this.props}
onSavePress={this.onSavePress}
onTestPress={this.onTestPress}
onAdvancedSettingsPress={this.onAdvancedSettingsPress}
onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange}
/>
@@ -82,6 +94,7 @@ EditDownloadClientModalContentConnector.propTypes = {
setDownloadClientFieldValue: PropTypes.func.isRequired,
saveDownloadClient: PropTypes.func.isRequired,
testDownloadClient: PropTypes.func.isRequired,
toggleAdvancedSettings: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};

View File

@@ -32,7 +32,7 @@ const enableOptions = [
get value() {
return translate('NoChange');
},
disabled: true,
isDisabled: true,
},
{
key: 'enabled',

View File

@@ -1,27 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import EditImportListExclusionModalContentConnector from './EditImportListExclusionModalContentConnector';
function EditImportListExclusionModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
size={sizes.MEDIUM}
isOpen={isOpen}
onModalClose={onModalClose}
>
<EditImportListExclusionModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
EditImportListExclusionModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditImportListExclusionModal;

View File

@@ -0,0 +1,41 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditImportListExclusionModalContent from './EditImportListExclusionModalContent';
interface EditImportListExclusionModalProps {
id?: number;
isOpen: boolean;
onModalClose: () => void;
onDeleteImportListExclusionPress?: () => void;
}
function EditImportListExclusionModal(
props: EditImportListExclusionModalProps
) {
const { isOpen, onModalClose, ...otherProps } = props;
const dispatch = useDispatch();
const onModalClosePress = useCallback(() => {
dispatch(
clearPendingChanges({
section: 'settings.importListExclusions',
})
);
onModalClose();
}, [dispatch, onModalClose]);
return (
<Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={onModalClosePress}>
<EditImportListExclusionModalContent
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default EditImportListExclusionModal;

View File

@@ -1,43 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditImportListExclusionModal from './EditImportListExclusionModal';
function mapStateToProps() {
return {};
}
const mapDispatchToProps = {
clearPendingChanges
};
class EditImportListExclusionModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.clearPendingChanges({ section: 'settings.importListExclusions' });
this.props.onModalClose();
};
//
// Render
render() {
return (
<EditImportListExclusionModal
{...this.props}
onModalClose={this.onModalClose}
/>
);
}
}
EditImportListExclusionModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};
export default connect(mapStateToProps, mapDispatchToProps)(EditImportListExclusionModalConnector);

View File

@@ -1,139 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Alert from 'Components/Alert';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
import { numberSettingShape, stringSettingShape } from 'Helpers/Props/Shapes/settingShape';
import translate from 'Utilities/String/translate';
import styles from './EditImportListExclusionModalContent.css';
function EditImportListExclusionModalContent(props) {
const {
id,
isFetching,
error,
isSaving,
saveError,
item,
onInputChange,
onSavePress,
onModalClose,
onDeleteImportListExclusionPress,
...otherProps
} = props;
const {
title,
tvdbId
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{id ? translate('EditImportListExclusion') : translate('AddImportListExclusion')}
</ModalHeader>
<ModalBody className={styles.body}>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<Alert kind={kinds.DANGER}>
{translate('AddImportListExclusionError')}
</Alert>
}
{
!isFetching && !error &&
<Form
{...otherProps}
>
<FormGroup>
<FormLabel>{translate('Title')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="title"
helpText={translate('SeriesTitleToExcludeHelpText')}
{...title}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('TvdbId')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="tvdbId"
helpText={translate('TvdbIdExcludeHelpText')}
{...tvdbId}
onChange={onInputChange}
/>
</FormGroup>
</Form>
}
</ModalBody>
<ModalFooter>
{
id &&
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteImportListExclusionPress}
>
{translate('Delete')}
</Button>
}
<Button
onPress={onModalClose}
>
{translate('Cancel')}
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
const ImportListExclusionShape = {
title: PropTypes.shape(stringSettingShape).isRequired,
tvdbId: PropTypes.shape(numberSettingShape).isRequired
};
EditImportListExclusionModalContent.propTypes = {
id: PropTypes.number,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.shape(ImportListExclusionShape).isRequired,
onInputChange: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onDeleteImportListExclusionPress: PropTypes.func
};
export default EditImportListExclusionModalContent;

View File

@@ -0,0 +1,188 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { inputTypes, kinds } from 'Helpers/Props';
import {
saveImportListExclusion,
setImportListExclusionValue,
} from 'Store/Actions/settingsActions';
import selectSettings from 'Store/Selectors/selectSettings';
import ImportListExclusion from 'typings/ImportListExclusion';
import { PendingSection } from 'typings/pending';
import translate from 'Utilities/String/translate';
import styles from './EditImportListExclusionModalContent.css';
const newImportListExclusion = {
title: '',
tvdbId: 0,
};
interface EditImportListExclusionModalContentProps {
id?: number;
onModalClose: () => void;
onDeleteImportListExclusionPress?: () => void;
}
function createImportListExclusionSelector(id?: number) {
return createSelector(
(state: AppState) => state.settings.importListExclusions,
(importListExclusions) => {
const { isFetching, error, isSaving, saveError, pendingChanges, items } =
importListExclusions;
const mapping = id
? items.find((i) => i.id === id)
: newImportListExclusion;
const settings = selectSettings(mapping, pendingChanges, saveError);
return {
id,
isFetching,
error,
isSaving,
saveError,
item: settings.settings as PendingSection<ImportListExclusion>,
...settings,
};
}
);
}
function EditImportListExclusionModalContent(
props: EditImportListExclusionModalContentProps
) {
const { id, onModalClose, onDeleteImportListExclusionPress } = props;
const dispatch = useDispatch();
const dispatchSetImportListExclusionValue = (payload: {
name: string;
value: string | number;
}) => {
// @ts-expect-error 'setImportListExclusionValue' isn't typed yet
dispatch(setImportListExclusionValue(payload));
};
const { isFetching, isSaving, item, error, saveError, ...otherProps } =
useSelector(createImportListExclusionSelector(props.id));
const previousIsSaving = usePrevious(isSaving);
const { title, tvdbId } = item;
useEffect(() => {
if (!id) {
Object.keys(newImportListExclusion).forEach((name) => {
dispatchSetImportListExclusionValue({
name,
value:
newImportListExclusion[name as keyof typeof newImportListExclusion],
});
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (previousIsSaving && !isSaving && !saveError) {
onModalClose();
}
});
const onSavePress = useCallback(() => {
dispatch(saveImportListExclusion({ id }));
}, [dispatch, id]);
const onInputChange = useCallback(
(payload: { name: string; value: string | number }) => {
// @ts-expect-error 'setImportListExclusionValue' isn't typed yet
dispatch(setImportListExclusionValue(payload));
},
[dispatch]
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{id
? translate('EditImportListExclusion')
: translate('AddImportListExclusion')}
</ModalHeader>
<ModalBody className={styles.body}>
{isFetching && <LoadingIndicator />}
{!isFetching && !!error && (
<Alert kind={kinds.DANGER}>
{translate('AddImportListExclusionError')}
</Alert>
)}
{!isFetching && !error && (
<Form {...otherProps}>
<FormGroup>
<FormLabel>{translate('Title')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="title"
helpText={translate('SeriesTitleToExcludeHelpText')}
{...title}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('TvdbId')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="tvdbId"
helpText={translate('TvdbIdExcludeHelpText')}
{...tvdbId}
onChange={onInputChange}
/>
</FormGroup>
</Form>
)}
</ModalBody>
<ModalFooter>
{id && (
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteImportListExclusionPress}
>
{translate('Delete')}
</Button>
)}
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
export default EditImportListExclusionModalContent;

View File

@@ -1,117 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { saveImportListExclusion, setImportListExclusionValue } from 'Store/Actions/settingsActions';
import selectSettings from 'Store/Selectors/selectSettings';
import EditImportListExclusionModalContent from './EditImportListExclusionModalContent';
const newImportListExclusion = {
title: '',
tvdbId: 0
};
function createImportListExclusionSelector() {
return createSelector(
(state, { id }) => id,
(state) => state.settings.importListExclusions,
(id, importListExclusions) => {
const {
isFetching,
error,
isSaving,
saveError,
pendingChanges,
items
} = importListExclusions;
const mapping = id ? items.find((i) => i.id === id) : newImportListExclusion;
const settings = selectSettings(mapping, pendingChanges, saveError);
return {
id,
isFetching,
error,
isSaving,
saveError,
item: settings.settings,
...settings
};
}
);
}
function createMapStateToProps() {
return createSelector(
createImportListExclusionSelector(),
(importListExclusion) => {
return {
...importListExclusion
};
}
);
}
const mapDispatchToProps = {
setImportListExclusionValue,
saveImportListExclusion
};
class EditImportListExclusionModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
if (!this.props.id) {
Object.keys(newImportListExclusion).forEach((name) => {
this.props.setImportListExclusionValue({
name,
value: newImportListExclusion[name]
});
});
}
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose();
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setImportListExclusionValue({ name, value });
};
onSavePress = () => {
this.props.saveImportListExclusion({ id: this.props.id });
};
//
// Render
render() {
return (
<EditImportListExclusionModalContent
{...this.props}
onSavePress={this.onSavePress}
onInputChange={this.onInputChange}
/>
);
}
}
EditImportListExclusionModalContentConnector.propTypes = {
id: PropTypes.number,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
setImportListExclusionValue: PropTypes.func.isRequired,
saveImportListExclusion: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditImportListExclusionModalContentConnector);

View File

@@ -1,25 +0,0 @@
.importListExclusion {
display: flex;
align-items: stretch;
margin-bottom: 10px;
height: 30px;
border-bottom: 1px solid var(--borderColor);
line-height: 30px;
}
.title {
@add-mixin truncate;
flex: 0 1 600px;
}
.tvdbId {
flex: 0 0 70px;
}
.actions {
display: flex;
justify-content: flex-end;
flex: 1 0 auto;
padding-right: 10px;
}

View File

@@ -2,9 +2,6 @@
// Please do not change this file!
interface CssExports {
'actions': string;
'importListExclusion': string;
'title': string;
'tvdbId': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -1,112 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import { icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import EditImportListExclusionModalConnector from './EditImportListExclusionModalConnector';
import styles from './ImportListExclusion.css';
class ImportListExclusion extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditImportListExclusionModalOpen: false,
isDeleteImportListExclusionModalOpen: false
};
}
//
// Listeners
onEditImportListExclusionPress = () => {
this.setState({ isEditImportListExclusionModalOpen: true });
};
onEditImportListExclusionModalClose = () => {
this.setState({ isEditImportListExclusionModalOpen: false });
};
onDeleteImportListExclusionPress = () => {
this.setState({
isEditImportListExclusionModalOpen: false,
isDeleteImportListExclusionModalOpen: true
});
};
onDeleteImportListExclusionModalClose = () => {
this.setState({ isDeleteImportListExclusionModalOpen: false });
};
onConfirmDeleteImportListExclusion = () => {
this.props.onConfirmDeleteImportListExclusion(this.props.id);
};
//
// Render
render() {
const {
id,
title,
tvdbId
} = this.props;
return (
<div
className={classNames(
styles.importListExclusion
)}
>
<div className={styles.title}>{title}</div>
<div className={styles.tvdbId}>{tvdbId}</div>
<div className={styles.actions}>
<Link
onPress={this.onEditImportListExclusionPress}
>
<Icon name={icons.EDIT} />
</Link>
</div>
<EditImportListExclusionModalConnector
id={id}
isOpen={this.state.isEditImportListExclusionModalOpen}
onModalClose={this.onEditImportListExclusionModalClose}
onDeleteImportListExclusionPress={this.onDeleteImportListExclusionPress}
/>
<ConfirmModal
isOpen={this.state.isDeleteImportListExclusionModalOpen}
kind={kinds.DANGER}
title={translate('DeleteImportListExclusion')}
message={translate('DeleteImportListExclusionMessageText')}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeleteImportListExclusion}
onCancel={this.onDeleteImportListExclusionModalClose}
/>
</div>
);
}
}
ImportListExclusion.propTypes = {
id: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
tvdbId: PropTypes.number.isRequired,
onConfirmDeleteImportListExclusion: PropTypes.func.isRequired
};
ImportListExclusion.defaultProps = {
// The drag preview will not connect the drag handle.
connectDragSource: (node) => node
};
export default ImportListExclusion;

View File

@@ -0,0 +1,6 @@
.actions {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 35px;
white-space: nowrap;
}

View File

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

View File

@@ -0,0 +1,68 @@
import React, { useCallback } from 'react';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { icons, kinds } from 'Helpers/Props';
import ImportListExclusion from 'typings/ImportListExclusion';
import translate from 'Utilities/String/translate';
import EditImportListExclusionModal from './EditImportListExclusionModal';
import styles from './ImportListExclusionRow.css';
interface ImportListExclusionRowProps extends ImportListExclusion {
onConfirmDeleteImportListExclusion: (id: number) => void;
}
function ImportListExclusionRow(props: ImportListExclusionRowProps) {
const { id, title, tvdbId, onConfirmDeleteImportListExclusion } = props;
const [
isEditImportListExclusionModalOpen,
setEditImportListExclusionModalOpen,
setEditImportListExclusionModalClosed,
] = useModalOpenState(false);
const [
isDeleteImportListExclusionModalOpen,
setDeleteImportListExclusionModalOpen,
setDeleteImportListExclusionModalClosed,
] = useModalOpenState(false);
const onConfirmDeleteImportListExclusionPress = useCallback(() => {
onConfirmDeleteImportListExclusion(id);
}, [id, onConfirmDeleteImportListExclusion]);
return (
<TableRow>
<TableRowCell>{title}</TableRowCell>
<TableRowCell>{tvdbId}</TableRowCell>
<TableRowCell className={styles.actions}>
<IconButton
name={icons.EDIT}
onPress={setEditImportListExclusionModalOpen}
/>
</TableRowCell>
<EditImportListExclusionModal
id={id}
isOpen={isEditImportListExclusionModalOpen}
onModalClose={setEditImportListExclusionModalClosed}
onDeleteImportListExclusionPress={setDeleteImportListExclusionModalOpen}
/>
<ConfirmModal
isOpen={isDeleteImportListExclusionModalOpen}
kind={kinds.DANGER}
title={translate('DeleteImportListExclusion')}
message={translate('DeleteImportListExclusionMessageText')}
confirmLabel={translate('Delete')}
onConfirm={onConfirmDeleteImportListExclusionPress}
onCancel={setDeleteImportListExclusionModalClosed}
/>
</TableRow>
);
}
export default ImportListExclusionRow;

View File

@@ -1,23 +0,0 @@
.importListExclusionsHeader {
display: flex;
margin-bottom: 10px;
font-weight: bold;
}
.title {
flex: 0 1 600px;
}
.tvdbId {
flex: 0 0 70px;
}
.addImportListExclusion {
display: flex;
justify-content: flex-end;
padding-right: 10px;
}
.addButton {
text-align: center;
}

View File

@@ -3,9 +3,6 @@
interface CssExports {
'addButton': string;
'addImportListExclusion': string;
'importListExclusionsHeader': string;
'title': string;
'tvdbId': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -1,105 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import PageSectionContent from 'Components/Page/PageSectionContent';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import EditImportListExclusionModalConnector from './EditImportListExclusionModalConnector';
import ImportListExclusion from './ImportListExclusion';
import styles from './ImportListExclusions.css';
class ImportListExclusions extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isAddImportListExclusionModalOpen: false
};
}
//
// Listeners
onAddImportListExclusionPress = () => {
this.setState({ isAddImportListExclusionModalOpen: true });
};
onModalClose = () => {
this.setState({ isAddImportListExclusionModalOpen: false });
};
//
// Render
render() {
const {
items,
onConfirmDeleteImportListExclusion,
...otherProps
} = this.props;
return (
<FieldSet legend={translate('ImportListExclusions')}>
<PageSectionContent
errorMessage={translate('ImportListExclusionsLoadError')}
{...otherProps}
>
<div className={styles.importListExclusionsHeader}>
<div className={styles.title}>
{translate('Title')}
</div>
<div className={styles.tvdbId}>
{translate('TvdbId')}
</div>
</div>
<div>
{
items.map((item, index) => {
return (
<ImportListExclusion
key={item.id}
{...item}
{...otherProps}
index={index}
onConfirmDeleteImportListExclusion={onConfirmDeleteImportListExclusion}
/>
);
})
}
</div>
<div className={styles.addImportListExclusion}>
<Link
className={styles.addButton}
onPress={this.onAddImportListExclusionPress}
>
<Icon name={icons.ADD} />
</Link>
</div>
<EditImportListExclusionModalConnector
isOpen={this.state.isAddImportListExclusionModalOpen}
onModalClose={this.onModalClose}
/>
</PageSectionContent>
</FieldSet>
);
}
}
ImportListExclusions.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteImportListExclusion: PropTypes.func.isRequired
};
export default ImportListExclusions;

View File

@@ -0,0 +1,232 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FieldSet from 'Components/FieldSet';
import IconButton from 'Components/Link/IconButton';
import PageSectionContent from 'Components/Page/PageSectionContent';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TablePager from 'Components/Table/TablePager';
import TableRow from 'Components/Table/TableRow';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { icons } from 'Helpers/Props';
import * as importListExclusionActions from 'Store/Actions/Settings/importListExclusions';
import {
registerPagePopulator,
unregisterPagePopulator,
} from 'Utilities/pagePopulator';
import translate from 'Utilities/String/translate';
import EditImportListExclusionModal from './EditImportListExclusionModal';
import ImportListExclusionRow from './ImportListExclusionRow';
const COLUMNS = [
{
name: 'title',
label: () => translate('Title'),
isVisible: true,
isSortable: true,
},
{
name: 'tvdbid',
label: () => translate('TvdbId'),
isVisible: true,
isSortable: true,
},
{
name: 'actions',
isVisible: true,
isSortable: false,
},
];
function createImportListExlucionsSelector() {
return createSelector(
(state: AppState) => state.settings.importListExclusions,
(importListExclusions) => {
return {
...importListExclusions,
};
}
);
}
function ImportListExclusions() {
const history = useHistory();
const useCurrentPage = history.action === 'POP';
const dispatch = useDispatch();
const fetchImportListExclusions = useCallback(() => {
dispatch(importListExclusionActions.fetchImportListExclusions());
}, [dispatch]);
const deleteImportListExclusion = useCallback(
(payload: { id: number }) => {
dispatch(importListExclusionActions.deleteImportListExclusion(payload));
},
[dispatch]
);
const gotoImportListExclusionFirstPage = useCallback(() => {
dispatch(importListExclusionActions.gotoImportListExclusionFirstPage());
}, [dispatch]);
const gotoImportListExclusionPreviousPage = useCallback(() => {
dispatch(importListExclusionActions.gotoImportListExclusionPreviousPage());
}, [dispatch]);
const gotoImportListExclusionNextPage = useCallback(() => {
dispatch(importListExclusionActions.gotoImportListExclusionNextPage());
}, [dispatch]);
const gotoImportListExclusionLastPage = useCallback(() => {
dispatch(importListExclusionActions.gotoImportListExclusionLastPage());
}, [dispatch]);
const gotoImportListExclusionPage = useCallback(
(page: number) => {
dispatch(
importListExclusionActions.gotoImportListExclusionPage({ page })
);
},
[dispatch]
);
const setImportListExclusionSort = useCallback(
(sortKey: { sortKey: string }) => {
dispatch(
importListExclusionActions.setImportListExclusionSort({ sortKey })
);
},
[dispatch]
);
const setImportListTableOption = useCallback(
(payload: { pageSize: number }) => {
dispatch(
importListExclusionActions.setImportListExclusionTableOption(payload)
);
if (payload.pageSize) {
dispatch(importListExclusionActions.gotoImportListExclusionFirstPage());
}
},
[dispatch]
);
const repopulate = useCallback(() => {
gotoImportListExclusionFirstPage();
}, [gotoImportListExclusionFirstPage]);
useEffect(() => {
registerPagePopulator(repopulate);
if (useCurrentPage) {
fetchImportListExclusions();
} else {
gotoImportListExclusionFirstPage();
}
return () => unregisterPagePopulator(repopulate);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onConfirmDeleteImportListExclusion = useCallback(
(id: number) => {
deleteImportListExclusion({ id });
repopulate();
},
[deleteImportListExclusion, repopulate]
);
const selected = useSelector(createImportListExlucionsSelector());
const {
isFetching,
isPopulated,
items,
pageSize,
sortKey,
error,
sortDirection,
totalRecords,
...otherProps
} = selected;
const [
isAddImportListExclusionModalOpen,
setAddImportListExclusionModalOpen,
setAddImportListExclusionModalClosed,
] = useModalOpenState(false);
const isFetchingForFirstTime = isFetching && !isPopulated;
return (
<FieldSet legend={translate('ImportListExclusions')}>
<PageSectionContent
errorMessage={translate('ImportListExclusionsLoadError')}
isFetching={isFetchingForFirstTime}
isPopulated={isPopulated}
error={error}
>
<Table
columns={COLUMNS}
canModifyColumns={false}
pageSize={pageSize}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={setImportListExclusionSort}
onTableOptionChange={setImportListTableOption}
>
<TableBody>
{items.map((item) => {
return (
<ImportListExclusionRow
key={item.id}
{...item}
onConfirmDeleteImportListExclusion={
onConfirmDeleteImportListExclusion
}
/>
);
})}
<TableRow>
<TableRowCell />
<TableRowCell />
<TableRowCell>
<IconButton
name={icons.ADD}
onPress={setAddImportListExclusionModalOpen}
/>
</TableRowCell>
</TableRow>
</TableBody>
</Table>
<TablePager
totalRecords={totalRecords}
pageSize={pageSize}
isFetching={isFetching}
onFirstPagePress={gotoImportListExclusionFirstPage}
onPreviousPagePress={gotoImportListExclusionPreviousPage}
onNextPagePress={gotoImportListExclusionNextPage}
onLastPagePress={gotoImportListExclusionLastPage}
onPageSelect={gotoImportListExclusionPage}
{...otherProps}
/>
<EditImportListExclusionModal
isOpen={isAddImportListExclusionModalOpen}
onModalClose={setAddImportListExclusionModalClosed}
/>
</PageSectionContent>
</FieldSet>
);
}
export default ImportListExclusions;

View File

@@ -1,59 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { deleteImportListExclusion, fetchImportListExclusions } from 'Store/Actions/settingsActions';
import ImportListExclusions from './ImportListExclusions';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.importListExclusions,
(importListExclusions) => {
return {
...importListExclusions
};
}
);
}
const mapDispatchToProps = {
fetchImportListExclusions,
deleteImportListExclusion
};
class ImportListExclusionsConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchImportListExclusions();
}
//
// Listeners
onConfirmDeleteImportListExclusion = (id) => {
this.props.deleteImportListExclusion({ id });
};
//
// Render
render() {
return (
<ImportListExclusions
{...this.state}
{...this.props}
onConfirmDeleteImportListExclusion={this.onConfirmDeleteImportListExclusion}
/>
);
}
}
ImportListExclusionsConnector.propTypes = {
fetchImportListExclusions: PropTypes.func.isRequired,
deleteImportListExclusion: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportListExclusionsConnector);

View File

@@ -7,7 +7,7 @@ import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import { icons } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import ImportListsExclusionsConnector from './ImportListExclusions/ImportListExclusionsConnector';
import ImportListsExclusions from './ImportListExclusions/ImportListExclusions';
import ImportListsConnector from './ImportLists/ImportListsConnector';
import ManageImportListsModal from './ImportLists/Manage/ManageImportListsModal';
import ImportListOptions from './Options/ImportListOptions';
@@ -113,7 +113,7 @@ class ImportListSettings extends Component {
onChildStateChange={this.onChildStateChange}
/>
<ImportListsExclusionsConnector />
<ImportListsExclusions />
<ManageImportListsModal
isOpen={isManageImportListsOpen}
onModalClose={this.onManageImportListsModalClose}

View File

@@ -19,6 +19,7 @@ import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton';
import formatShortTimeSpan from 'Utilities/Date/formatShortTimeSpan';
import translate from 'Utilities/String/translate';
import styles from './EditImportListModalContent.css';
@@ -38,6 +39,7 @@ function EditImportListModalContent(props) {
onModalClose,
onSavePress,
onTestPress,
onAdvancedSettingsPress,
onDeleteImportListPress,
...otherProps
} = props;
@@ -288,6 +290,12 @@ function EditImportListModalContent(props) {
</Button>
}
<AdvancedSettingsButton
advancedSettings={advancedSettings}
onAdvancedSettingsPress={onAdvancedSettingsPress}
showLabel={false}
/>
<SpinnerErrorButton
isSpinning={isTesting}
error={saveError}
@@ -327,6 +335,7 @@ EditImportListModalContent.propTypes = {
onModalClose: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onTestPress: PropTypes.func.isRequired,
onAdvancedSettingsPress: PropTypes.func.isRequired,
onDeleteImportListPress: PropTypes.func
};

View File

@@ -2,7 +2,13 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { saveImportList, setImportListFieldValue, setImportListValue, testImportList } from 'Store/Actions/settingsActions';
import {
saveImportList,
setImportListFieldValue,
setImportListValue,
testImportList,
toggleAdvancedSettings
} from 'Store/Actions/settingsActions';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import EditImportListModalContent from './EditImportListModalContent';
@@ -23,7 +29,8 @@ const mapDispatchToProps = {
setImportListValue,
setImportListFieldValue,
saveImportList,
testImportList
testImportList,
toggleAdvancedSettings
};
class EditImportListModalContentConnector extends Component {
@@ -56,6 +63,10 @@ class EditImportListModalContentConnector extends Component {
this.props.testImportList({ id: this.props.id });
};
onAdvancedSettingsPress = () => {
this.props.toggleAdvancedSettings();
};
//
// Render
@@ -65,6 +76,7 @@ class EditImportListModalContentConnector extends Component {
{...this.props}
onSavePress={this.onSavePress}
onTestPress={this.onTestPress}
onAdvancedSettingsPress={this.onAdvancedSettingsPress}
onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange}
/>
@@ -82,6 +94,7 @@ EditImportListModalContentConnector.propTypes = {
setImportListFieldValue: PropTypes.func.isRequired,
saveImportList: PropTypes.func.isRequired,
testImportList: PropTypes.func.isRequired,
toggleAdvancedSettings: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};

View File

@@ -31,7 +31,7 @@ const autoAddOptions = [
get value() {
return translate('NoChange');
},
disabled: true,
isDisabled: true,
},
{
key: 'enabled',

View File

@@ -32,7 +32,7 @@ const enableOptions = [
get value() {
return translate('NoChange');
},
disabled: true,
isDisabled: true,
},
{
key: 'enabled',

View File

@@ -150,7 +150,12 @@ const mediaInfoTokens = [
const otherTokens = [
{ token: '{Release Group}', example: 'Rls Grp' },
{ token: '{Custom Formats}', example: 'iNTERNAL' }
{ token: '{Custom Formats}', example: 'iNTERNAL' },
{ token: '{Custom Format:FormatName}', example: 'AMZN' }
];
const otherAnimeTokens = [
{ token: '{Release Hash}', example: 'ABCDEFGH' }
];
const originalTokens = [
@@ -534,6 +539,24 @@ class NamingModal extends Component {
}
)
}
{
anime && otherAnimeTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
</FieldSet>

View File

@@ -26,7 +26,7 @@
.token {
flex: 0 0 50%;
padding: 6px 6px;
padding: 6px;
background-color: var(--popoverTitleBackgroundColor);
font-family: $monoSpaceFontFamily;
}
@@ -36,7 +36,7 @@
align-items: center;
justify-content: space-between;
flex: 0 0 50%;
padding: 6px 6px;
padding: 6px;
background-color: var(--popoverBodyBackgroundColor);
.footNote {

View File

@@ -15,8 +15,7 @@ function TheTvdb(props) {
<div className={styles.title}>
{translate('TheTvdb')}
</div>
<InlineMarkdown data={translate('SeriesAndEpisodeInformationIsProvidedByTheTVDB')} />
<InlineMarkdown data={translate('SeriesAndEpisodeInformationIsProvidedByTheTVDB', { url: 'https://www.thetvdb.com/subscribe' })} />
</div>
</div>

View File

@@ -14,6 +14,7 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton';
import translate from 'Utilities/String/translate';
import NotificationEventItems from './NotificationEventItems';
import styles from './EditNotificationModalContent.css';
@@ -32,6 +33,7 @@ function EditNotificationModalContent(props) {
onModalClose,
onSavePress,
onTestPress,
onAdvancedSettingsPress,
onDeleteNotificationPress,
...otherProps
} = props;
@@ -136,6 +138,12 @@ function EditNotificationModalContent(props) {
</Button>
}
<AdvancedSettingsButton
advancedSettings={advancedSettings}
onAdvancedSettingsPress={onAdvancedSettingsPress}
showLabel={false}
/>
<SpinnerErrorButton
isSpinning={isTesting}
error={saveError}
@@ -175,6 +183,7 @@ EditNotificationModalContent.propTypes = {
onModalClose: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onTestPress: PropTypes.func.isRequired,
onAdvancedSettingsPress: PropTypes.func.isRequired,
onDeleteNotificationPress: PropTypes.func
};

View File

@@ -2,7 +2,13 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { saveNotification, setNotificationFieldValue, setNotificationValue, testNotification } from 'Store/Actions/settingsActions';
import {
saveNotification,
setNotificationFieldValue,
setNotificationValue,
testNotification,
toggleAdvancedSettings
} from 'Store/Actions/settingsActions';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import EditNotificationModalContent from './EditNotificationModalContent';
@@ -23,7 +29,8 @@ const mapDispatchToProps = {
setNotificationValue,
setNotificationFieldValue,
saveNotification,
testNotification
testNotification,
toggleAdvancedSettings
};
class EditNotificationModalContentConnector extends Component {
@@ -56,6 +63,10 @@ class EditNotificationModalContentConnector extends Component {
this.props.testNotification({ id: this.props.id });
};
onAdvancedSettingsPress = () => {
this.props.toggleAdvancedSettings();
};
//
// Render
@@ -65,6 +76,7 @@ class EditNotificationModalContentConnector extends Component {
{...this.props}
onSavePress={this.onSavePress}
onTestPress={this.onTestPress}
onAdvancedSettingsPress={this.onAdvancedSettingsPress}
onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange}
/>
@@ -82,6 +94,7 @@ EditNotificationModalContentConnector.propTypes = {
setNotificationFieldValue: PropTypes.func.isRequired,
saveNotification: PropTypes.func.isRequired,
testNotification: PropTypes.func.isRequired,
toggleAdvancedSettings: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};

View File

@@ -86,10 +86,10 @@ function EditSpecificationModalContent(props) {
<InlineMarkdown data={translate('ConditionUsingRegularExpressions')} />
</div>
<div>
<InlineMarkdown data={translate('RegularExpressionsTutorialLink')} />
<InlineMarkdown data={translate('RegularExpressionsTutorialLink', { url: 'https://www.regular-expressions.info/tutorial.html' })} />
</div>
<div>
<InlineMarkdown data={translate('RegularExpressionsCanBeTested')} />
<InlineMarkdown data={translate('RegularExpressionsCanBeTested', { url: 'http://regexstorm.net/tester' })} />
</div>
</Alert>
}

View File

@@ -1,9 +1,11 @@
import { createAction } from 'redux-actions';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
import createServerSideCollectionHandlers from 'Store/Actions/Creators/createServerSideCollectionHandlers';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks';
import createSetTableOptionReducer from 'Store/Actions/Creators/Reducers/createSetTableOptionReducer';
import { createThunk, handleThunks } from 'Store/thunks';
import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
//
// Variables
@@ -14,6 +16,13 @@ const section = 'settings.importListExclusions';
// Actions Types
export const FETCH_IMPORT_LIST_EXCLUSIONS = 'settings/importListExclusions/fetchImportListExclusions';
export const GOTO_FIRST_IMPORT_LIST_EXCLUSION_PAGE = 'settings/importListExclusions/gotoImportListExclusionFirstPage';
export const GOTO_PREVIOUS_IMPORT_LIST_EXCLUSION_PAGE = 'settings/importListExclusions/gotoImportListExclusionPreviousPage';
export const GOTO_NEXT_IMPORT_LIST_EXCLUSION_PAGE = 'settings/importListExclusions/gotoImportListExclusionNextPage';
export const GOTO_LAST_IMPORT_LIST_EXCLUSION_PAGE = 'settings/importListExclusions/gotoImportListExclusionLastPage';
export const GOTO_IMPORT_LIST_EXCLUSION_PAGE = 'settings/importListExclusions/gotoImportListExclusionPage';
export const SET_IMPORT_LIST_EXCLUSION_SORT = 'settings/importListExclusions/setImportListExclusionSort';
export const SET_IMPORT_LIST_EXCLUSION_TABLE_OPTION = 'settings/importListExclusions/setImportListExclusionTableOption';
export const SAVE_IMPORT_LIST_EXCLUSION = 'settings/importListExclusions/saveImportListExclusion';
export const DELETE_IMPORT_LIST_EXCLUSION = 'settings/importListExclusions/deleteImportListExclusion';
export const SET_IMPORT_LIST_EXCLUSION_VALUE = 'settings/importListExclusions/setImportListExclusionValue';
@@ -22,9 +31,16 @@ export const SET_IMPORT_LIST_EXCLUSION_VALUE = 'settings/importListExclusions/se
// Action Creators
export const fetchImportListExclusions = createThunk(FETCH_IMPORT_LIST_EXCLUSIONS);
export const gotoImportListExclusionFirstPage = createThunk(GOTO_FIRST_IMPORT_LIST_EXCLUSION_PAGE);
export const gotoImportListExclusionPreviousPage = createThunk(GOTO_PREVIOUS_IMPORT_LIST_EXCLUSION_PAGE);
export const gotoImportListExclusionNextPage = createThunk(GOTO_NEXT_IMPORT_LIST_EXCLUSION_PAGE);
export const gotoImportListExclusionLastPage = createThunk(GOTO_LAST_IMPORT_LIST_EXCLUSION_PAGE);
export const gotoImportListExclusionPage = createThunk(GOTO_IMPORT_LIST_EXCLUSION_PAGE);
export const setImportListExclusionSort = createThunk(SET_IMPORT_LIST_EXCLUSION_SORT);
export const saveImportListExclusion = createThunk(SAVE_IMPORT_LIST_EXCLUSION);
export const deleteImportListExclusion = createThunk(DELETE_IMPORT_LIST_EXCLUSION);
export const setImportListExclusionTableOption = createAction(SET_IMPORT_LIST_EXCLUSION_TABLE_OPTION);
export const setImportListExclusionValue = createAction(SET_IMPORT_LIST_EXCLUSION_VALUE, (payload) => {
return {
section,
@@ -44,6 +60,7 @@ export default {
isFetching: false,
isPopulated: false,
error: null,
pageSize: 20,
items: [],
isSaving: false,
saveError: null,
@@ -53,17 +70,31 @@ export default {
//
// Action Handlers
actionHandlers: {
[FETCH_IMPORT_LIST_EXCLUSIONS]: createFetchHandler(section, '/importlistexclusion'),
actionHandlers: handleThunks({
...createServerSideCollectionHandlers(
section,
'/importlistexclusion/paged',
fetchImportListExclusions,
{
[serverSideCollectionHandlers.FETCH]: FETCH_IMPORT_LIST_EXCLUSIONS,
[serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_IMPORT_LIST_EXCLUSION_PAGE,
[serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_IMPORT_LIST_EXCLUSION_PAGE,
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_IMPORT_LIST_EXCLUSION_PAGE,
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_IMPORT_LIST_EXCLUSION_PAGE,
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_IMPORT_LIST_EXCLUSION_PAGE,
[serverSideCollectionHandlers.SORT]: SET_IMPORT_LIST_EXCLUSION_SORT
}
),
[SAVE_IMPORT_LIST_EXCLUSION]: createSaveProviderHandler(section, '/importlistexclusion'),
[DELETE_IMPORT_LIST_EXCLUSION]: createRemoveItemHandler(section, '/importlistexclusion')
},
}),
//
// Reducers
reducers: {
[SET_IMPORT_LIST_EXCLUSION_VALUE]: createSetSettingValueReducer(section)
[SET_IMPORT_LIST_EXCLUSION_VALUE]: createSetSettingValueReducer(section),
[SET_IMPORT_LIST_EXCLUSION_TABLE_OPTION]: createSetTableOptionReducer(section)
}
};

View File

@@ -163,6 +163,7 @@ export const actionHandlers = handleThunks({
languages: item.languages,
releaseGroup: item.releaseGroup,
indexerFlags: item.indexerFlags,
releaseType: item.releaseType,
downloadId: item.downloadId
};
});

View File

@@ -250,6 +250,12 @@ export const defaultState = {
label: () => translate('SeasonPack'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.BOOL
},
{
name: 'episodeRequested',
label: () => translate('EpisodeRequested'),
type: filterBuilderTypes.EXACT,
valueType: filterBuilderValueTypes.BOOL
}
],

View File

@@ -1,5 +1,4 @@
import { createAction } from 'redux-actions';
import indexerFlags from 'Store/Actions/Settings/indexerFlags';
import { handleThunks } from 'Store/thunks';
import createHandleActions from './Creators/createHandleActions';
import autoTaggings from './Settings/autoTaggings';
@@ -13,6 +12,7 @@ import general from './Settings/general';
import importListExclusions from './Settings/importListExclusions';
import importListOptions from './Settings/importListOptions';
import importLists from './Settings/importLists';
import indexerFlags from './Settings/indexerFlags';
import indexerOptions from './Settings/indexerOptions';
import indexers from './Settings/indexers';
import languages from './Settings/languages';
@@ -91,7 +91,8 @@ export const defaultState = {
};
export const persistState = [
'settings.advancedSettings'
'settings.advancedSettings',
'settings.importListExclusions.pageSize'
];
//

View File

@@ -0,0 +1,23 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Series from 'Series/Series';
function createMultiSeriesSelector(seriesIds: number[]) {
return createSelector(
(state: AppState) => state.series.itemMap,
(state: AppState) => state.series.items,
(itemMap, allSeries) => {
return seriesIds.reduce((acc: Series[], seriesId) => {
const series = allSeries[itemMap[seriesId]];
if (series) {
acc.push(series);
}
return acc;
}, []);
}
);
}
export default createMultiSeriesSelector;

View File

@@ -1,45 +1,44 @@
import { createSelector } from 'reselect';
import AppSectionState, {
AppSectionItemState,
} from 'App/State/AppSectionState';
import { AppSectionItemState } from 'App/State/AppSectionState';
import AppState from 'App/State/AppState';
import SettingsAppState from 'App/State/SettingsAppState';
import selectSettings from 'Store/Selectors/selectSettings';
import { PendingSection } from 'typings/pending';
type SettingNames = keyof Omit<AppState['settings'], 'advancedSettings'>;
type GetSectionState<Name extends SettingNames> = AppState['settings'][Name];
type GetSettingsSectionItemType<Name extends SettingNames> =
GetSectionState<Name> extends AppSectionItemState<infer R>
? R
: GetSectionState<Name> extends AppSectionState<infer R>
? R
type SectionsWithItemNames = {
[K in keyof SettingsAppState]: SettingsAppState[K] extends AppSectionItemState<unknown>
? K
: never;
}[keyof SettingsAppState];
type AppStateWithPending<Name extends SettingNames> = {
item?: GetSettingsSectionItemType<Name>;
pendingChanges?: Partial<GetSettingsSectionItemType<Name>>;
saveError?: Error;
} & GetSectionState<Name>;
type GetSectionState<Name extends SectionsWithItemNames> =
SettingsAppState[Name];
type GetSettingsSectionItemType<Name extends SectionsWithItemNames> =
GetSectionState<Name> extends AppSectionItemState<infer R> ? R : never;
function createSettingsSectionSelector<Name extends SettingNames>(
section: Name
) {
function createSettingsSectionSelector<
Name extends SectionsWithItemNames,
T extends GetSettingsSectionItemType<Name>
>(section: Name) {
return createSelector(
(state: AppState) => state.settings[section],
(sectionSettings) => {
const { item, pendingChanges, saveError, ...other } =
sectionSettings as AppStateWithPending<Name>;
const { item, pendingChanges, ...other } = sectionSettings;
const { settings, ...rest } = selectSettings(
item,
pendingChanges,
saveError
);
const saveError =
'saveError' in sectionSettings ? sectionSettings.saveError : undefined;
const {
settings,
pendingChanges: selectedPendingChanges,
...rest
} = selectSettings(item, pendingChanges, saveError);
return {
...other,
saveError,
settings: settings as PendingSection<GetSettingsSectionItemType<Name>>,
settings: settings as PendingSection<T>,
pendingChanges: selectedPendingChanges as Partial<T>,
...rest,
};
}

View File

@@ -10,15 +10,6 @@
width: 100%;
}
.commandName {
display: inline-block;
min-width: 220px;
}
.userAgent {
color: #b0b0b0;
}
.queued,
.started,
.ended {

View File

@@ -2,14 +2,12 @@
// Please do not change this file!
interface CssExports {
'actions': string;
'commandName': string;
'duration': string;
'ended': string;
'queued': string;
'started': string;
'trigger': string;
'triggerContent': string;
'userAgent': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -1,279 +0,0 @@
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import { icons, kinds } from 'Helpers/Props';
import formatDate from 'Utilities/Date/formatDate';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import styles from './QueuedTaskRow.css';
function getStatusIconProps(status, message) {
const title = titleCase(status);
switch (status) {
case 'queued':
return {
name: icons.PENDING,
title
};
case 'started':
return {
name: icons.REFRESH,
isSpinning: true,
title
};
case 'completed':
return {
name: icons.CHECK,
kind: kinds.SUCCESS,
title: message === 'Completed' ? title : `${title}: ${message}`
};
case 'failed':
return {
name: icons.FATAL,
kind: kinds.DANGER,
title: `${title}: ${message}`
};
default:
return {
name: icons.UNKNOWN,
title
};
}
}
function getFormattedDates(props) {
const {
queued,
started,
ended,
showRelativeDates,
shortDateFormat
} = props;
if (showRelativeDates) {
return {
queuedAt: moment(queued).fromNow(),
startedAt: started ? moment(started).fromNow() : '-',
endedAt: ended ? moment(ended).fromNow() : '-'
};
}
return {
queuedAt: formatDate(queued, shortDateFormat),
startedAt: started ? formatDate(started, shortDateFormat) : '-',
endedAt: ended ? formatDate(ended, shortDateFormat) : '-'
};
}
class QueuedTaskRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
...getFormattedDates(props),
isCancelConfirmModalOpen: false
};
this._updateTimeoutId = null;
}
componentDidMount() {
this.setUpdateTimer();
}
componentDidUpdate(prevProps) {
const {
queued,
started,
ended
} = this.props;
if (
queued !== prevProps.queued ||
started !== prevProps.started ||
ended !== prevProps.ended
) {
this.setState(getFormattedDates(this.props));
}
}
componentWillUnmount() {
if (this._updateTimeoutId) {
this._updateTimeoutId = clearTimeout(this._updateTimeoutId);
}
}
//
// Control
setUpdateTimer() {
this._updateTimeoutId = setTimeout(() => {
this.setState(getFormattedDates(this.props));
this.setUpdateTimer();
}, 30000);
}
//
// Listeners
onCancelPress = () => {
this.setState({
isCancelConfirmModalOpen: true
});
};
onAbortCancel = () => {
this.setState({
isCancelConfirmModalOpen: false
});
};
//
// Render
render() {
const {
trigger,
commandName,
queued,
started,
ended,
status,
duration,
message,
clientUserAgent,
longDateFormat,
timeFormat,
onCancelPress
} = this.props;
const {
queuedAt,
startedAt,
endedAt,
isCancelConfirmModalOpen
} = this.state;
let triggerIcon = icons.QUICK;
if (trigger === 'manual') {
triggerIcon = icons.INTERACTIVE;
} else if (trigger === 'scheduled') {
triggerIcon = icons.SCHEDULED;
}
return (
<TableRow>
<TableRowCell className={styles.trigger}>
<span className={styles.triggerContent}>
<Icon
name={triggerIcon}
title={titleCase(trigger)}
/>
<Icon
{...getStatusIconProps(status, message)}
/>
</span>
</TableRowCell>
<TableRowCell>
<span className={styles.commandName}>
{commandName}
</span>
{
clientUserAgent ?
<span className={styles.userAgent} title={translate('TaskUserAgentTooltip')}>
{translate('From')}: {clientUserAgent}
</span> :
null
}
</TableRowCell>
<TableRowCell
className={styles.queued}
title={formatDateTime(queued, longDateFormat, timeFormat)}
>
{queuedAt}
</TableRowCell>
<TableRowCell
className={styles.started}
title={formatDateTime(started, longDateFormat, timeFormat)}
>
{startedAt}
</TableRowCell>
<TableRowCell
className={styles.ended}
title={formatDateTime(ended, longDateFormat, timeFormat)}
>
{endedAt}
</TableRowCell>
<TableRowCell className={styles.duration}>
{formatTimeSpan(duration)}
</TableRowCell>
<TableRowCell
className={styles.actions}
>
{
status === 'queued' &&
<IconButton
title={translate('RemovedFromTaskQueue')}
name={icons.REMOVE}
onPress={this.onCancelPress}
/>
}
</TableRowCell>
<ConfirmModal
isOpen={isCancelConfirmModalOpen}
kind={kinds.DANGER}
title={translate('Cancel')}
message={translate('CancelPendingTask')}
confirmLabel={translate('YesCancel')}
cancelLabel={translate('NoLeaveIt')}
onConfirm={onCancelPress}
onCancel={this.onAbortCancel}
/>
</TableRow>
);
}
}
QueuedTaskRow.propTypes = {
trigger: PropTypes.string.isRequired,
commandName: PropTypes.string.isRequired,
queued: PropTypes.string.isRequired,
started: PropTypes.string,
ended: PropTypes.string,
status: PropTypes.string.isRequired,
duration: PropTypes.string,
message: PropTypes.string,
clientUserAgent: PropTypes.string,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
onCancelPress: PropTypes.func.isRequired
};
export default QueuedTaskRow;

View File

@@ -0,0 +1,238 @@
import moment from 'moment';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { CommandBody } from 'Commands/Command';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { icons, kinds } from 'Helpers/Props';
import { cancelCommand } from 'Store/Actions/commandActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatDate from 'Utilities/Date/formatDate';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
import titleCase from 'Utilities/String/titleCase';
import translate from 'Utilities/String/translate';
import QueuedTaskRowNameCell from './QueuedTaskRowNameCell';
import styles from './QueuedTaskRow.css';
function getStatusIconProps(status: string, message: string | undefined) {
const title = titleCase(status);
switch (status) {
case 'queued':
return {
name: icons.PENDING,
title,
};
case 'started':
return {
name: icons.REFRESH,
isSpinning: true,
title,
};
case 'completed':
return {
name: icons.CHECK,
kind: kinds.SUCCESS,
title: message === 'Completed' ? title : `${title}: ${message}`,
};
case 'failed':
return {
name: icons.FATAL,
kind: kinds.DANGER,
title: `${title}: ${message}`,
};
default:
return {
name: icons.UNKNOWN,
title,
};
}
}
function getFormattedDates(
queued: string,
started: string | undefined,
ended: string | undefined,
showRelativeDates: boolean,
shortDateFormat: string
) {
if (showRelativeDates) {
return {
queuedAt: moment(queued).fromNow(),
startedAt: started ? moment(started).fromNow() : '-',
endedAt: ended ? moment(ended).fromNow() : '-',
};
}
return {
queuedAt: formatDate(queued, shortDateFormat),
startedAt: started ? formatDate(started, shortDateFormat) : '-',
endedAt: ended ? formatDate(ended, shortDateFormat) : '-',
};
}
interface QueuedTimes {
queuedAt: string;
startedAt: string;
endedAt: string;
}
export interface QueuedTaskRowProps {
id: number;
trigger: string;
commandName: string;
queued: string;
started?: string;
ended?: string;
status: string;
duration?: string;
message?: string;
body: CommandBody;
clientUserAgent?: string;
}
export default function QueuedTaskRow(props: QueuedTaskRowProps) {
const {
id,
trigger,
commandName,
queued,
started,
ended,
status,
duration,
message,
body,
clientUserAgent,
} = props;
const dispatch = useDispatch();
const { longDateFormat, shortDateFormat, showRelativeDates, timeFormat } =
useSelector(createUISettingsSelector());
const updateTimeTimeoutId = useRef<ReturnType<typeof setTimeout> | null>(
null
);
const [times, setTimes] = useState<QueuedTimes>(
getFormattedDates(
queued,
started,
ended,
showRelativeDates,
shortDateFormat
)
);
const [
isCancelConfirmModalOpen,
openCancelConfirmModal,
closeCancelConfirmModal,
] = useModalOpenState(false);
const handleCancelPress = useCallback(() => {
dispatch(cancelCommand({ id }));
}, [id, dispatch]);
useEffect(() => {
updateTimeTimeoutId.current = setTimeout(() => {
setTimes(
getFormattedDates(
queued,
started,
ended,
showRelativeDates,
shortDateFormat
)
);
}, 30000);
return () => {
if (updateTimeTimeoutId.current) {
clearTimeout(updateTimeTimeoutId.current);
}
};
}, [queued, started, ended, showRelativeDates, shortDateFormat, setTimes]);
const { queuedAt, startedAt, endedAt } = times;
let triggerIcon = icons.QUICK;
if (trigger === 'manual') {
triggerIcon = icons.INTERACTIVE;
} else if (trigger === 'scheduled') {
triggerIcon = icons.SCHEDULED;
}
return (
<TableRow>
<TableRowCell className={styles.trigger}>
<span className={styles.triggerContent}>
<Icon name={triggerIcon} title={titleCase(trigger)} />
<Icon {...getStatusIconProps(status, message)} />
</span>
</TableRowCell>
<QueuedTaskRowNameCell
commandName={commandName}
body={body}
clientUserAgent={clientUserAgent}
/>
<TableRowCell
className={styles.queued}
title={formatDateTime(queued, longDateFormat, timeFormat)}
>
{queuedAt}
</TableRowCell>
<TableRowCell
className={styles.started}
title={formatDateTime(started, longDateFormat, timeFormat)}
>
{startedAt}
</TableRowCell>
<TableRowCell
className={styles.ended}
title={formatDateTime(ended, longDateFormat, timeFormat)}
>
{endedAt}
</TableRowCell>
<TableRowCell className={styles.duration}>
{formatTimeSpan(duration)}
</TableRowCell>
<TableRowCell className={styles.actions}>
{status === 'queued' && (
<IconButton
title={translate('RemovedFromTaskQueue')}
name={icons.REMOVE}
onPress={openCancelConfirmModal}
/>
)}
</TableRowCell>
<ConfirmModal
isOpen={isCancelConfirmModalOpen}
kind={kinds.DANGER}
title={translate('Cancel')}
message={translate('CancelPendingTask')}
confirmLabel={translate('YesCancel')}
cancelLabel={translate('NoLeaveIt')}
onConfirm={handleCancelPress}
onCancel={closeCancelConfirmModal}
/>
</TableRow>
);
}

View File

@@ -1,31 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { cancelCommand } from 'Store/Actions/commandActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import QueuedTaskRow from './QueuedTaskRow';
function createMapStateToProps() {
return createSelector(
createUISettingsSelector(),
(uiSettings) => {
return {
showRelativeDates: uiSettings.showRelativeDates,
shortDateFormat: uiSettings.shortDateFormat,
longDateFormat: uiSettings.longDateFormat,
timeFormat: uiSettings.timeFormat
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onCancelPress() {
dispatch(cancelCommand({
id: props.id
}));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(QueuedTaskRow);

View File

@@ -0,0 +1,8 @@
.commandName {
display: inline-block;
min-width: 220px;
}
.userAgent {
color: #b0b0b0;
}

View File

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

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { CommandBody } from 'Commands/Command';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import createMultiSeriesSelector from 'Store/Selectors/createMultiSeriesSelector';
import translate from 'Utilities/String/translate';
import styles from './QueuedTaskRowNameCell.css';
export interface QueuedTaskRowNameCellProps {
commandName: string;
body: CommandBody;
clientUserAgent?: string;
}
export default function QueuedTaskRowNameCell(
props: QueuedTaskRowNameCellProps
) {
const { commandName, body, clientUserAgent } = props;
const seriesIds = [...(body.seriesIds ?? [])];
if (body.seriesId) {
seriesIds.push(body.seriesId);
}
const series = useSelector(createMultiSeriesSelector(seriesIds));
const sortedSeries = series.sort((a, b) =>
a.sortTitle.localeCompare(b.sortTitle)
);
return (
<TableRowCell>
<span className={styles.commandName}>
{commandName}
{sortedSeries.length ? (
<span> - {sortedSeries.map((s) => s.title).join(', ')}</span>
) : null}
{body.seasonNumber ? (
<span>
{' '}
{translate('SeasonNumberToken', {
seasonNumber: body.seasonNumber,
})}
</span>
) : null}
</span>
{clientUserAgent ? (
<span
className={styles.userAgent}
title={translate('TaskUserAgentTooltip')}
>
{translate('From')}: {clientUserAgent}
</span>
) : null}
</TableRowCell>
);
}

View File

@@ -1,90 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import FieldSet from 'Components/FieldSet';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import translate from 'Utilities/String/translate';
import QueuedTaskRowConnector from './QueuedTaskRowConnector';
const columns = [
{
name: 'trigger',
label: '',
isVisible: true
},
{
name: 'commandName',
label: () => translate('Name'),
isVisible: true
},
{
name: 'queued',
label: () => translate('Queued'),
isVisible: true
},
{
name: 'started',
label: () => translate('Started'),
isVisible: true
},
{
name: 'ended',
label: () => translate('Ended'),
isVisible: true
},
{
name: 'duration',
label: () => translate('Duration'),
isVisible: true
},
{
name: 'actions',
isVisible: true
}
];
function QueuedTasks(props) {
const {
isFetching,
isPopulated,
items
} = props;
return (
<FieldSet legend={translate('Queue')}>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
isPopulated &&
<Table
columns={columns}
>
<TableBody>
{
items.map((item) => {
return (
<QueuedTaskRowConnector
key={item.id}
{...item}
/>
);
})
}
</TableBody>
</Table>
}
</FieldSet>
);
}
QueuedTasks.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
items: PropTypes.array.isRequired
};
export default QueuedTasks;

View File

@@ -0,0 +1,74 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import FieldSet from 'Components/FieldSet';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { fetchCommands } from 'Store/Actions/commandActions';
import translate from 'Utilities/String/translate';
import QueuedTaskRow from './QueuedTaskRow';
const columns = [
{
name: 'trigger',
label: '',
isVisible: true,
},
{
name: 'commandName',
label: () => translate('Name'),
isVisible: true,
},
{
name: 'queued',
label: () => translate('Queued'),
isVisible: true,
},
{
name: 'started',
label: () => translate('Started'),
isVisible: true,
},
{
name: 'ended',
label: () => translate('Ended'),
isVisible: true,
},
{
name: 'duration',
label: () => translate('Duration'),
isVisible: true,
},
{
name: 'actions',
isVisible: true,
},
];
export default function QueuedTasks() {
const dispatch = useDispatch();
const { isFetching, isPopulated, items } = useSelector(
(state: AppState) => state.commands
);
useEffect(() => {
dispatch(fetchCommands());
}, [dispatch]);
return (
<FieldSet legend={translate('Queue')}>
{isFetching && !isPopulated && <LoadingIndicator />}
{isPopulated && (
<Table columns={columns}>
<TableBody>
{items.map((item) => {
return <QueuedTaskRow key={item.id} {...item} />;
})}
</TableBody>
</Table>
)}
</FieldSet>
);
}

View File

@@ -1,46 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchCommands } from 'Store/Actions/commandActions';
import QueuedTasks from './QueuedTasks';
function createMapStateToProps() {
return createSelector(
(state) => state.commands,
(commands) => {
return commands;
}
);
}
const mapDispatchToProps = {
dispatchFetchCommands: fetchCommands
};
class QueuedTasksConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.dispatchFetchCommands();
}
//
// Render
render() {
return (
<QueuedTasks
{...this.props}
/>
);
}
}
QueuedTasksConnector.propTypes = {
dispatchFetchCommands: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(QueuedTasksConnector);

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import translate from 'Utilities/String/translate';
import QueuedTasksConnector from './Queued/QueuedTasksConnector';
import QueuedTasks from './Queued/QueuedTasks';
import ScheduledTasksConnector from './Scheduled/ScheduledTasksConnector';
function Tasks() {
@@ -10,7 +10,7 @@ function Tasks() {
<PageContent title={translate('Tasks')}>
<PageContentBody>
<ScheduledTasksConnector />
<QueuedTasksConnector />
<QueuedTasks />
</PageContentBody>
</PageContent>
);

View File

@@ -12,6 +12,7 @@ import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager';
import { align, icons, kinds } from 'Helpers/Props';
import getFilterValue from 'Utilities/Filter/getFilterValue';
@@ -180,6 +181,16 @@ class CutoffUnmet extends Component {
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
{...otherProps}
columns={columns}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}

View File

@@ -12,6 +12,7 @@ import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
import TablePager from 'Components/Table/TablePager';
import { align, icons, kinds } from 'Helpers/Props';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
@@ -193,6 +194,16 @@ class Missing extends Component {
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
{...otherProps}
columns={columns}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}

View File

@@ -3,13 +3,16 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<!-- Chrome, Opera, and Firefox OS -->
<meta name="theme-color" content="#3a3f51" />
<!-- Windows Phone -->
<meta name="msapplication-navbutton-color" content="#3a3f51" />
<!-- Android/Apple Phone -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="format-detection" content="telephone=no">
<meta name="description" content="Sonarr" />

View File

@@ -3,13 +3,16 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<!-- Chrome, Opera, and Firefox OS -->
<meta name="theme-color" content="#3a3f51" />
<!-- Windows Phone -->
<meta name="msapplication-navbutton-color" content="#3a3f51" />
<!-- Android/Apple Phone -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="format-detection" content="telephone=no">
<meta name="description" content="Sonarr" />

View File

@@ -0,0 +1,6 @@
import ModelBase from 'App/ModelBase';
export default interface ImportListExclusion extends ModelBase {
tvdbId: number;
title: string;
}

View File

@@ -1,7 +1,21 @@
export interface ValidationFailure {
propertyName: string;
errorMessage: string;
severity: 'error' | 'warning';
}
export interface ValidationError extends ValidationFailure {
isWarning: false;
}
export interface ValidationWarning extends ValidationFailure {
isWarning: true;
}
export interface Pending<T> {
value: T;
errors: any[];
warnings: any[];
errors: ValidationError[];
warnings: ValidationWarning[];
}
export type PendingSection<T> = {

View File

@@ -10,7 +10,7 @@
"watch": "webpack --watch --config ./frontend/build/webpack.config.js",
"lint": "eslint --config frontend/.eslintrc.js --ignore-path frontend/.eslintignore frontend/",
"lint-fix": "yarn lint --fix",
"stylelint": "stylelint frontend/**/*.css --config frontend/.stylelintrc"
"stylelint": "stylelint \"frontend/**/*.css\" --config frontend/.stylelintrc"
},
"repository": "https://github.com/Sonarr/Sonarr",
"author": "Team Sonarr",
@@ -28,8 +28,7 @@
"@fortawesome/react-fontawesome": "0.2.0",
"@juggle/resize-observer": "3.4.0",
"@microsoft/signalr": "6.0.21",
"@sentry/browser": "7.51.2",
"@sentry/integrations": "7.51.2",
"@sentry/browser": "7.100.0",
"@types/node": "18.16.8",
"@types/react": "18.2.6",
"@types/react-dom": "18.2.4",

View File

@@ -98,6 +98,35 @@
<RootNamespace Condition="'$(SonarrProject)'=='true'">$(MSBuildProjectName.Replace('Sonarr','NzbDrone'))</RootNamespace>
</PropertyGroup>
<ItemGroup Condition="'$(TestProject)'!='true'">
<!-- Annotates .NET assemblies with repository information including SHA -->
<!-- Sentry uses this to link directly to GitHub at the exact version/file/line -->
<!-- This is built-in on .NET 8 and can be removed once the project is updated -->
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" />
</ItemGroup>
<!-- Sentry specific configuration: Only in Release mode -->
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<!-- https://docs.sentry.io/platforms/dotnet/configuration/msbuild/ -->
<!-- OrgSlug, ProjectSlug and AuthToken are required.
They can be set below, via argument to 'msbuild -p:' or environment variable -->
<SentryOrg></SentryOrg>
<SentryProject></SentryProject>
<SentryUrl></SentryUrl> <!-- If empty, assumed to be sentry.io -->
<SentryAuthToken></SentryAuthToken> <!-- Use env var instead: SENTRY_AUTH_TOKEN -->
<!-- Upload PDBs to Sentry, enabling stack traces with line numbers and file paths
without the need to deploy the application with PDBs -->
<SentryUploadSymbols>true</SentryUploadSymbols>
<!-- Source Link settings -->
<!-- https://github.com/dotnet/sourcelink/blob/main/docs/README.md#publishrepositoryurl -->
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<!-- Embeds all source code in the respective PDB. This can make it a bit bigger but since it'll be uploaded
to Sentry and not distributed to run on the server, it helps debug crashes while making releases smaller -->
<EmbedAllSources>true</EmbedAllSources>
</PropertyGroup>
<!-- Standard testing packages -->
<ItemGroup Condition="'$(TestProject)'=='true'">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />

View File

@@ -4,6 +4,7 @@ using System.Linq;
using FluentAssertions;
using NLog;
using NUnit.Framework;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Instrumentation.Sentry;
using NzbDrone.Test.Common;
@@ -26,7 +27,7 @@ namespace NzbDrone.Common.Test.InstrumentationTests
[SetUp]
public void Setup()
{
_subject = new SentryTarget("https://aaaaaaaaaaaaaaaaaaaaaaaaaa@sentry.io/111111");
_subject = new SentryTarget("https://aaaaaaaaaaaaaaaaaaaaaaaaaa@sentry.io/111111", Mocker.GetMock<IAppFolderInfo>().Object);
}
private LogEventInfo GivenLogEvent(LogLevel level, Exception ex, string message)

View File

@@ -1,12 +1,15 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.NetworkInformation;
using System.Net.Security;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using NLog;
using NzbDrone.Common.Cache;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http.Proxy;
@@ -28,11 +31,14 @@ namespace NzbDrone.Common.Http.Dispatchers
private readonly ICached<System.Net.Http.HttpClient> _httpClientCache;
private readonly ICached<CredentialCache> _credentialCache;
private readonly Logger _logger;
public ManagedHttpDispatcher(IHttpProxySettingsProvider proxySettingsProvider,
ICreateManagedWebProxy createManagedWebProxy,
ICertificateValidationService certificateValidationService,
IUserAgentBuilder userAgentBuilder,
ICacheManager cacheManager)
ICacheManager cacheManager,
Logger logger)
{
_proxySettingsProvider = proxySettingsProvider;
_createManagedWebProxy = createManagedWebProxy;
@@ -41,6 +47,8 @@ namespace NzbDrone.Common.Http.Dispatchers
_httpClientCache = cacheManager.GetCache<System.Net.Http.HttpClient>(typeof(ManagedHttpDispatcher));
_credentialCache = cacheManager.GetCache<CredentialCache>(typeof(ManagedHttpDispatcher), "credentialcache");
_logger = logger;
}
public async Task<HttpResponse> GetResponseAsync(HttpRequest request, CookieContainer cookies)
@@ -247,7 +255,27 @@ namespace NzbDrone.Common.Http.Dispatchers
return _credentialCache.Get("credentialCache", () => new CredentialCache());
}
private static async ValueTask<Stream> onConnect(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
private bool HasRoutableIPv4Address()
{
// Get all IPv4 addresses from all interfaces and return true if there are any with non-loopback addresses
try
{
var networkInterfaces = NetworkInterface.GetAllNetworkInterfaces();
return networkInterfaces.Any(ni =>
ni.OperationalStatus == OperationalStatus.Up &&
ni.GetIPProperties().UnicastAddresses.Any(ip =>
ip.Address.AddressFamily == AddressFamily.InterNetwork &&
!IPAddress.IsLoopback(ip.Address)));
}
catch (Exception e)
{
_logger.Debug(e, "Caught exception while GetAllNetworkInterfaces assuming IPv4 connectivity: {0}", e.Message);
return true;
}
}
private async ValueTask<Stream> onConnect(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
{
// Until .NET supports an implementation of Happy Eyeballs (https://tools.ietf.org/html/rfc8305#section-2), let's make IPv4 fallback work in a simple way.
// This issue is being tracked at https://github.com/dotnet/runtime/issues/26177 and expected to be fixed in .NET 6.
@@ -270,10 +298,10 @@ namespace NzbDrone.Common.Http.Dispatchers
}
catch
{
// very naively fallback to ipv4 permanently for this execution based on the response of the first connection attempt.
// note that this may cause users to eventually get switched to ipv4 (on a random failure when they are switching networks, for instance)
// but in the interest of keeping this implementation simple, this is acceptable.
useIPv6 = false;
// Do not retry IPv6 if a routable IPv4 address is available, otherwise continue to attempt IPv6 connections.
var routableIPv4 = HasRoutableIPv4Address();
_logger.Info("IPv4 is available: {0}, IPv6 will be {1}", routableIPv4, routableIPv4 ? "disabled" : "left enabled");
useIPv6 = !routableIPv4;
}
finally
{

View File

@@ -39,7 +39,7 @@ namespace NzbDrone.Common.Instrumentation
RegisterDebugger();
}
RegisterSentry(updateApp);
RegisterSentry(updateApp, appFolderInfo);
if (updateApp)
{
@@ -60,7 +60,7 @@ namespace NzbDrone.Common.Instrumentation
LogManager.ReconfigExistingLoggers();
}
private static void RegisterSentry(bool updateClient)
private static void RegisterSentry(bool updateClient, IAppFolderInfo appFolderInfo)
{
string dsn;
@@ -80,7 +80,7 @@ namespace NzbDrone.Common.Instrumentation
Target target;
try
{
target = new SentryTarget(dsn)
target = new SentryTarget(dsn, appFolderInfo)
{
Name = "sentryTarget",
Layout = "${message}"

View File

@@ -9,6 +9,7 @@ using NLog;
using NLog.Common;
using NLog.Targets;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using Sentry;
namespace NzbDrone.Common.Instrumentation.Sentry
@@ -96,7 +97,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry
public bool FilterEvents { get; set; }
public bool SentryEnabled { get; set; }
public SentryTarget(string dsn)
public SentryTarget(string dsn, IAppFolderInfo appFolderInfo)
{
_sdk = SentrySdk.Init(o =>
{
@@ -104,9 +105,33 @@ namespace NzbDrone.Common.Instrumentation.Sentry
o.AttachStacktrace = true;
o.MaxBreadcrumbs = 200;
o.Release = BuildInfo.Release;
o.BeforeSend = x => SentryCleanser.CleanseEvent(x);
o.BeforeBreadcrumb = x => SentryCleanser.CleanseBreadcrumb(x);
o.SetBeforeSend(x => SentryCleanser.CleanseEvent(x));
o.SetBeforeBreadcrumb(x => SentryCleanser.CleanseBreadcrumb(x));
o.Environment = BuildInfo.Branch;
// Crash free run statistics (sends a ping for healthy and for crashes sessions)
o.AutoSessionTracking = true;
// Caches files in the event device is offline
// Sentry creates a 'sentry' sub directory, no need to concat here
o.CacheDirectoryPath = appFolderInfo.GetAppDataPath();
// default environment is production
if (!RuntimeInfo.IsProduction)
{
if (RuntimeInfo.IsDevelopment)
{
o.Environment = "development";
}
else if (RuntimeInfo.IsTesting)
{
o.Environment = "testing";
}
else
{
o.Environment = "other";
}
}
});
InitializeScope();
@@ -125,7 +150,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry
{
SentrySdk.ConfigureScope(scope =>
{
scope.User = new User
scope.User = new SentryUser
{
Id = HashUtil.AnonymousToken()
};
@@ -285,13 +310,21 @@ namespace NzbDrone.Common.Instrumentation.Sentry
}
}
var level = LoggingLevelMap[logEvent.Level];
var sentryEvent = new SentryEvent(logEvent.Exception)
{
Level = LoggingLevelMap[logEvent.Level],
Level = level,
Logger = logEvent.LoggerName,
Message = logEvent.FormattedMessage
};
if (level is SentryLevel.Fatal && logEvent.Exception is not null)
{
// Usages of 'fatal' here indicates the process will crash. In Sentry this is represented with
// the 'unhandled' exception flag
logEvent.Exception.SetSentryMechanism("Logger.Fatal", "Logger.Fatal was called", false);
}
sentryEvent.SetExtras(extras);
sentryEvent.SetFingerprint(fingerPrint);

View File

@@ -4,16 +4,16 @@
<DefineConstants Condition="'$(RuntimeIdentifier)' == 'linux-musl-x64' or '$(RuntimeIdentifier)' == 'linux-musl-arm64'">ISMUSL</DefineConstants>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DryIoc.dll" Version="5.4.1" />
<PackageReference Include="DryIoc.dll" Version="5.4.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NLog" Version="4.7.14" />
<PackageReference Include="NLog.Targets.Syslog" Version="6.0.3" />
<PackageReference Include="NLog.Extensions.Logging" Version="1.7.4" />
<PackageReference Include="Sentry" Version="3.23.1" />
<PackageReference Include="Sentry" Version="4.0.2" />
<PackageReference Include="SharpZipLib" Version="1.4.2" />
<PackageReference Include="System.Text.Json" Version="6.0.8" />
<PackageReference Include="System.Text.Json" Version="6.0.9" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
<PackageReference Include="System.Data.SQLite.Core.Servarr" Version="1.0.115.5-18" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="6.0.1" />

View File

@@ -0,0 +1,191 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Datastore.Migration;
using NzbDrone.Core.MediaFiles.MediaInfo;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.Datastore.Migration
{
[TestFixture]
public class release_typeFixture : MigrationTest<release_type>
{
[Test]
public void should_convert_single_episode_without_folder()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("EpisodeFiles").Row(new
{
SeriesId = 1,
SeasonNumber = 1,
RelativePath = "Season 01/S01E05.mkv",
Size = 125.Megabytes(),
DateAdded = DateTime.UtcNow.AddDays(-5),
OriginalFilePath = "Series.Title.S01E05.720p.HDTV.x265-Sonarr.mkv",
ReleaseGroup = "Sonarr",
Quality = new QualityModel(Quality.HDTV720p).ToJson(),
Languages = "[1]"
});
});
var items = db.Query<EpisodeFile203>("SELECT * FROM \"EpisodeFiles\"");
items.Should().HaveCount(1);
items.First().ReleaseType.Should().Be((int)ReleaseType.SingleEpisode);
}
[Test]
public void should_convert_single_episode_with_folder()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("EpisodeFiles").Row(new
{
SeriesId = 1,
SeasonNumber = 1,
RelativePath = "Season 01/S01E05.mkv",
Size = 125.Megabytes(),
DateAdded = DateTime.UtcNow.AddDays(-5),
OriginalFilePath = "Series.Title.S01E05.720p.HDTV.x265-Sonarr/S01E05.mkv",
ReleaseGroup = "Sonarr",
Quality = new QualityModel(Quality.HDTV720p).ToJson(),
Languages = "[1]"
});
});
var items = db.Query<EpisodeFile203>("SELECT * FROM \"EpisodeFiles\"");
items.Should().HaveCount(1);
items.First().ReleaseType.Should().Be((int)ReleaseType.SingleEpisode);
}
[Test]
public void should_convert_multi_episode_without_folder()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("EpisodeFiles").Row(new
{
SeriesId = 1,
SeasonNumber = 1,
RelativePath = "Season 01/S01E05.mkv",
Size = 125.Megabytes(),
DateAdded = DateTime.UtcNow.AddDays(-5),
OriginalFilePath = "Series.Title.S01E05E06.720p.HDTV.x265-Sonarr.mkv",
ReleaseGroup = "Sonarr",
Quality = new QualityModel(Quality.HDTV720p).ToJson(),
Languages = "[1]"
});
});
var items = db.Query<EpisodeFile203>("SELECT * FROM \"EpisodeFiles\"");
items.Should().HaveCount(1);
items.First().ReleaseType.Should().Be((int)ReleaseType.MultiEpisode);
}
[Test]
public void should_convert_multi_episode_with_folder()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("EpisodeFiles").Row(new
{
SeriesId = 1,
SeasonNumber = 1,
RelativePath = "Season 01/S01E05.mkv",
Size = 125.Megabytes(),
DateAdded = DateTime.UtcNow.AddDays(-5),
OriginalFilePath = "Series.Title.S01E05E06.720p.HDTV.x265-Sonarr/S01E05E06.mkv",
ReleaseGroup = "Sonarr",
Quality = new QualityModel(Quality.HDTV720p).ToJson(),
Languages = "[1]"
});
});
var items = db.Query<EpisodeFile203>("SELECT * FROM \"EpisodeFiles\"");
items.Should().HaveCount(1);
items.First().ReleaseType.Should().Be((int)ReleaseType.MultiEpisode);
}
[Test]
public void should_convert_season_pack_with_folder()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("EpisodeFiles").Row(new
{
SeriesId = 1,
SeasonNumber = 1,
RelativePath = "Season 01/S01E05.mkv",
Size = 125.Megabytes(),
DateAdded = DateTime.UtcNow.AddDays(-5),
OriginalFilePath = "Series.Title.S01.720p.HDTV.x265-Sonarr/S01E05.mkv",
ReleaseGroup = "Sonarr",
Quality = new QualityModel(Quality.HDTV720p).ToJson(),
Languages = "[1]"
});
});
var items = db.Query<EpisodeFile203>("SELECT * FROM \"EpisodeFiles\"");
items.Should().HaveCount(1);
items.First().ReleaseType.Should().Be((int)ReleaseType.SeasonPack);
}
[Test]
public void should_not_convert_episode_without_original_file_path()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("EpisodeFiles").Row(new
{
SeriesId = 1,
SeasonNumber = 1,
RelativePath = "Season 01/S01E05.mkv",
Size = 125.Megabytes(),
DateAdded = DateTime.UtcNow.AddDays(-5),
ReleaseGroup = "Sonarr",
Quality = new QualityModel(Quality.HDTV720p).ToJson(),
Languages = "[1]"
});
});
var items = db.Query<EpisodeFile203>("SELECT * FROM \"EpisodeFiles\"");
items.Should().HaveCount(1);
items.First().ReleaseType.Should().Be((int)ReleaseType.Unknown);
}
public class EpisodeFile203
{
public int Id { get; set; }
public int SeriesId { get; set; }
public int SeasonNumber { get; set; }
public string RelativePath { get; set; }
public long Size { get; set; }
public DateTime DateAdded { get; set; }
public string OriginalFilePath { get; set; }
public string SceneName { get; set; }
public string ReleaseGroup { get; set; }
public QualityModel Quality { get; set; }
public long IndexerFlags { get; set; }
public MediaInfoModel MediaInfo { get; set; }
public List<int> Languages { get; set; }
public long ReleaseType { get; set; }
}
}
}

View File

@@ -25,7 +25,7 @@ namespace NzbDrone.Core.Test.Framework
Mocker.SetConstant<IHttpProxySettingsProvider>(new HttpProxySettingsProvider(Mocker.Resolve<ConfigService>()));
Mocker.SetConstant<ICreateManagedWebProxy>(new ManagedWebProxyFactory(Mocker.Resolve<CacheManager>()));
Mocker.SetConstant<ICertificateValidationService>(new X509CertificateValidationService(Mocker.Resolve<ConfigService>(), TestLogger));
Mocker.SetConstant<IHttpDispatcher>(new ManagedHttpDispatcher(Mocker.Resolve<IHttpProxySettingsProvider>(), Mocker.Resolve<ICreateManagedWebProxy>(), Mocker.Resolve<ICertificateValidationService>(), Mocker.Resolve<UserAgentBuilder>(), Mocker.Resolve<CacheManager>()));
Mocker.SetConstant<IHttpDispatcher>(new ManagedHttpDispatcher(Mocker.Resolve<IHttpProxySettingsProvider>(), Mocker.Resolve<ICreateManagedWebProxy>(), Mocker.Resolve<ICertificateValidationService>(), Mocker.Resolve<UserAgentBuilder>(), Mocker.Resolve<CacheManager>(), TestLogger));
Mocker.SetConstant<IHttpClient>(new HttpClient(Array.Empty<IHttpRequestInterceptor>(), Mocker.Resolve<CacheManager>(), Mocker.Resolve<RateLimitService>(), Mocker.Resolve<IHttpDispatcher>(), TestLogger));
Mocker.SetConstant<ISonarrCloudRequestBuilder>(new SonarrCloudRequestBuilder());
}

View File

@@ -120,6 +120,17 @@ namespace NzbDrone.Core.Test.ImportListTests
private void WithImdbId()
{
_list1Series.First().ImdbId = "tt0496424";
Mocker.GetMock<ISearchForNewSeries>()
.Setup(s => s.SearchForNewSeriesByImdbId(_list1Series.First().ImdbId))
.Returns(
Builder<Series>
.CreateListOfSize(1)
.All()
.With(s => s.Title = "Breaking Bad")
.With(s => s.TvdbId = 81189)
.Build()
.ToList());
}
private void WithExistingSeries()
@@ -342,6 +353,7 @@ namespace NzbDrone.Core.Test.ImportListTests
public void should_add_new_series_from_single_list_to_library()
{
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithTvdbId();
WithList(1, true);
WithCleanLevel(ListSyncLevelType.Disabled);
@@ -358,6 +370,7 @@ namespace NzbDrone.Core.Test.ImportListTests
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
_importListFetch.Series.AddRange(_list2Series);
WithTvdbId();
WithList(1, true);
WithList(2, true);
@@ -376,6 +389,7 @@ namespace NzbDrone.Core.Test.ImportListTests
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
_importListFetch.Series.AddRange(_list2Series);
WithTvdbId();
WithList(1, true);
WithList(2, false);
@@ -422,12 +436,17 @@ namespace NzbDrone.Core.Test.ImportListTests
public void should_search_by_imdb_if_series_title_and_series_imdb()
{
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithImdbId();
Subject.Execute(_commandAll);
Mocker.GetMock<ISearchForNewSeries>()
.Verify(v => v.SearchForNewSeriesByImdbId(It.IsAny<string>()), Times.Once());
Mocker.GetMock<IAddSeriesService>()
.Verify(v => v.AddSeries(It.Is<List<Series>>(t => t.Count == 1), It.IsAny<bool>()));
}
[Test]
@@ -498,5 +517,18 @@ namespace NzbDrone.Core.Test.ImportListTests
Mocker.GetMock<IImportListExclusionService>()
.Verify(v => v.All(), Times.Never);
}
[Test]
public void should_not_add_if_tvdbid_is_0()
{
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithExcludedSeries();
Subject.Execute(_commandAll);
Mocker.GetMock<IAddSeriesService>()
.Verify(v => v.AddSeries(It.Is<List<Series>>(t => t.Count == 0), It.IsAny<bool>()));
}
}
}

View File

@@ -0,0 +1,83 @@
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation.Aggregators;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Tv;
using NzbDrone.Test.Common;
namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Aggregation.Aggregators
{
[TestFixture]
public class AggregateReleaseHashFixture : CoreTest<AggregateReleaseHash>
{
private Series _series;
[SetUp]
public void Setup()
{
_series = Builder<Series>.CreateNew().Build();
}
[Test]
public void should_prefer_file()
{
var fileEpisodeInfo = Parser.Parser.ParseTitle("[DHD] Series Title! - 08 (1280x720 10bit AAC) [ABCDEFGH]");
var folderEpisodeInfo = Parser.Parser.ParseTitle("[DHD] Series Title! - 08 [12345678]");
var downloadClientEpisodeInfo = Parser.Parser.ParseTitle("[DHD] Series Title! - 08 (1280x720 10bit AAC) [ABCD1234]");
var localEpisode = new LocalEpisode
{
FileEpisodeInfo = fileEpisodeInfo,
FolderEpisodeInfo = folderEpisodeInfo,
DownloadClientEpisodeInfo = downloadClientEpisodeInfo,
Path = @"C:\Test\Unsorted TV\Series.Title.S01\Series.Title.S01E01.mkv".AsOsAgnostic(),
Series = _series
};
Subject.Aggregate(localEpisode, null);
localEpisode.ReleaseHash.Should().Be("ABCDEFGH");
}
[Test]
public void should_fallback_to_downloadclient()
{
var fileEpisodeInfo = Parser.Parser.ParseTitle("[DHD] Series Title! - 08 (1280x720 10bit AAC)");
var downloadClientEpisodeInfo = Parser.Parser.ParseTitle("[DHD] Series Title! - 08 (1280x720 10bit AAC) [ABCD1234]");
var folderEpisodeInfo = Parser.Parser.ParseTitle("[DHD] Series Title! - 08 [12345678]");
var localEpisode = new LocalEpisode
{
FileEpisodeInfo = fileEpisodeInfo,
FolderEpisodeInfo = folderEpisodeInfo,
DownloadClientEpisodeInfo = downloadClientEpisodeInfo,
Path = @"C:\Test\Unsorted TV\Series.Title.S01\Series.Title.S01E01.WEB-DL.mkv".AsOsAgnostic(),
Series = _series
};
Subject.Aggregate(localEpisode, null);
localEpisode.ReleaseHash.Should().Be("ABCD1234");
}
[Test]
public void should_fallback_to_folder()
{
var fileEpisodeInfo = Parser.Parser.ParseTitle("[DHD] Series Title! - 08 (1280x720 10bit AAC)");
var downloadClientEpisodeInfo = Parser.Parser.ParseTitle("[DHD] Series Title! - 08 (1280x720 10bit AAC)");
var folderEpisodeInfo = Parser.Parser.ParseTitle("[DHD] Series Title! - 08 [12345678]");
var localEpisode = new LocalEpisode
{
FileEpisodeInfo = fileEpisodeInfo,
FolderEpisodeInfo = folderEpisodeInfo,
DownloadClientEpisodeInfo = downloadClientEpisodeInfo,
Path = @"C:\Test\Unsorted TV\Series.Title.S01\Series.Title.S01E01.WEB-DL.mkv".AsOsAgnostic(),
Series = _series
};
Subject.Aggregate(localEpisode, null);
localEpisode.ReleaseHash.Should().Be("12345678");
}
}
}

View File

@@ -49,6 +49,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport
_rejectedDecisions.Add(new ImportDecision(new LocalEpisode(), new Rejection("Rejected!")));
_rejectedDecisions.Add(new ImportDecision(new LocalEpisode(), new Rejection("Rejected!")));
_rejectedDecisions.Add(new ImportDecision(new LocalEpisode(), new Rejection("Rejected!")));
_rejectedDecisions.ForEach(r => r.LocalEpisode.FileEpisodeInfo = new ParsedEpisodeInfo());
foreach (var episode in episodes)
{
@@ -59,7 +60,8 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport
Episodes = new List<Episode> { episode },
Path = Path.Combine(series.Path, "30 Rock - S01E01 - Pilot.avi"),
Quality = new QualityModel(Quality.Bluray720p),
ReleaseGroup = "DRONE"
ReleaseGroup = "DRONE",
FileEpisodeInfo = new ParsedEpisodeInfo()
}));
}

View File

@@ -0,0 +1,146 @@
using System.Collections.Generic;
using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Organizer;
using NzbDrone.Core.Qualities;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Tv;
namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
{
[TestFixture]
public class CustomFormatsFixture : CoreTest<FileNameBuilder>
{
private Series _series;
private Episode _episode1;
private EpisodeFile _episodeFile;
private NamingConfig _namingConfig;
private List<CustomFormat> _customFormats;
[SetUp]
public void Setup()
{
_series = Builder<Series>
.CreateNew()
.With(s => s.Title = "South Park")
.Build();
_namingConfig = NamingConfig.Default;
_namingConfig.RenameEpisodes = true;
Mocker.GetMock<INamingConfigService>()
.Setup(c => c.GetConfig()).Returns(_namingConfig);
_episode1 = Builder<Episode>.CreateNew()
.With(e => e.Title = "City Sushi")
.With(e => e.SeasonNumber = 15)
.With(e => e.EpisodeNumber = 6)
.With(e => e.AbsoluteEpisodeNumber = 100)
.Build();
_episodeFile = new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p), ReleaseGroup = "SonarrTest" };
_customFormats = new List<CustomFormat>()
{
new CustomFormat()
{
Name = "INTERNAL",
IncludeCustomFormatWhenRenaming = true
},
new CustomFormat()
{
Name = "AMZN",
IncludeCustomFormatWhenRenaming = true
},
new CustomFormat()
{
Name = "NAME WITH SPACES",
IncludeCustomFormatWhenRenaming = true
},
new CustomFormat()
{
Name = "NotIncludedFormat",
IncludeCustomFormatWhenRenaming = false
}
};
Mocker.GetMock<IQualityDefinitionService>()
.Setup(v => v.Get(Moq.It.IsAny<Quality>()))
.Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v));
}
[TestCase("{Custom Formats}", "INTERNAL AMZN NAME WITH SPACES")]
public void should_replace_custom_formats(string format, string expected)
{
_namingConfig.StandardEpisodeFormat = format;
Subject.BuildFileName(new List<Episode> { _episode1 }, _series, _episodeFile, customFormats: _customFormats)
.Should().Be(expected);
}
[TestCase("{Custom Formats}", "")]
public void should_replace_custom_formats_with_no_custom_formats(string format, string expected)
{
_namingConfig.StandardEpisodeFormat = format;
Subject.BuildFileName(new List<Episode> { _episode1 }, _series, _episodeFile, customFormats: new List<CustomFormat>())
.Should().Be(expected);
}
[TestCase("{Custom Formats:-INTERNAL}", "AMZN NAME WITH SPACES")]
[TestCase("{Custom Formats:-NAME WITH SPACES}", "INTERNAL AMZN")]
[TestCase("{Custom Formats:-INTERNAL,NAME WITH SPACES}", "AMZN")]
[TestCase("{Custom Formats:INTERNAL}", "INTERNAL")]
[TestCase("{Custom Formats:NAME WITH SPACES}", "NAME WITH SPACES")]
[TestCase("{Custom Formats:INTERNAL,NAME WITH SPACES}", "INTERNAL NAME WITH SPACES")]
public void should_replace_custom_formats_with_filtered_names(string format, string expected)
{
_namingConfig.StandardEpisodeFormat = format;
Subject.BuildFileName(new List<Episode> { _episode1 }, _series, _episodeFile, customFormats: _customFormats)
.Should().Be(expected);
}
[TestCase("{Custom Formats:-}", "{Custom Formats:-}")]
[TestCase("{Custom Formats:}", "{Custom Formats:}")]
public void should_not_replace_custom_formats_due_to_invalid_token(string format, string expected)
{
_namingConfig.StandardEpisodeFormat = format;
Subject.BuildFileName(new List<Episode> { _episode1 }, _series, _episodeFile, customFormats: _customFormats)
.Should().Be(expected);
}
[TestCase("{Custom Format}", "")]
[TestCase("{Custom Format:INTERNAL}", "INTERNAL")]
[TestCase("{Custom Format:AMZN}", "AMZN")]
[TestCase("{Custom Format:NAME WITH SPACES}", "NAME WITH SPACES")]
[TestCase("{Custom Format:DOESNOTEXIST}", "")]
[TestCase("{Custom Format:INTERNAL} - {Custom Format:AMZN}", "INTERNAL - AMZN")]
[TestCase("{Custom Format:AMZN} - {Custom Format:INTERNAL}", "AMZN - INTERNAL")]
public void should_replace_custom_format(string format, string expected)
{
_namingConfig.StandardEpisodeFormat = format;
Subject.BuildFileName(new List<Episode> { _episode1 }, _series, _episodeFile, customFormats: _customFormats)
.Should().Be(expected);
}
[TestCase("{Custom Format}", "")]
[TestCase("{Custom Format:INTERNAL}", "")]
[TestCase("{Custom Format:AMZN}", "")]
public void should_replace_custom_format_with_no_custom_formats(string format, string expected)
{
_namingConfig.StandardEpisodeFormat = format;
Subject.BuildFileName(new List<Episode> { _episode1 }, _series, _episodeFile, customFormats: new List<CustomFormat>())
.Should().Be(expected);
}
}
}

View File

@@ -991,6 +991,28 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
result.Should().EndWith("HDR");
}
[Test]
public void should_replace_release_hash_with_stored_hash()
{
_namingConfig.StandardEpisodeFormat = "{Release Hash}";
_episodeFile.ReleaseHash = "ABCDEFGH";
Subject.BuildFileName(new List<Episode> { _episode1 }, _series, _episodeFile)
.Should().Be("ABCDEFGH");
}
[Test]
public void should_replace_null_release_hash_with_empty_string()
{
_namingConfig.StandardEpisodeFormat = "{Release Hash}";
_episodeFile.ReleaseHash = null;
Subject.BuildFileName(new List<Episode> { _episode1 }, _series, _episodeFile)
.Should().Be(string.Empty);
}
private void GivenMediaInfoModel(string videoCodec = "h264",
string audioCodec = "dts",
int audioChannels = 6,

View File

@@ -132,6 +132,8 @@ namespace NzbDrone.Core.Test.ParserTests
[TestCase("[Naruto-Kun.Hu] Dr Series S3 - 21 [1080p]", "Dr Series S3", 21, 0, 0)]
[TestCase("[Naruto-Kun.Hu] Series Title - 12 [1080p].mkv", "Series Title", 12, 0, 0)]
[TestCase("[Naruto-Kun.Hu] Anime Triangle - 08 [1080p].mkv", "Anime Triangle", 8, 0, 0)]
[TestCase("[Mystic Z-Team] Series Title Super - Episode 013 VF - Non-censuré [720p].mp4", "Series Title Super", 13, 0, 0)]
[TestCase("Series Title Kai Episodio 13 Audio Latino", "Series Title Kai", 13, 0, 0)]
// [TestCase("", "", 0, 0, 0)]
public void should_parse_absolute_numbers(string postTitle, string title, int absoluteEpisodeNumber, int seasonNumber, int episodeNumber)

View File

@@ -22,12 +22,42 @@ namespace NzbDrone.Core.Test.ParserTests
[TestCase("Series Title - 031 - The Resolution to Kill [Lunar].avi", "Lunar", "")]
[TestCase("[ACX]Series Title 01 Episode Name [Kosaka] [9C57891E].mkv", "ACX", "9C57891E")]
[TestCase("[S-T-D] Series Title! - 06 (1280x720 10bit AAC) [59B3F2EA].mkv", "S-T-D", "59B3F2EA")]
public void should_parse_absolute_numbers(string postTitle, string subGroup, string hash)
// These tests are dupes of the above, except with parenthesized hashes instead of square bracket
[TestCase("[SubDESU]_Show_Title_DxD_07_(1280x720_x264-AAC)_(6B7FD717)", "SubDESU", "6B7FD717")]
[TestCase("[Chihiro]_Show_Title!!_-_06_[848x480_H.264_AAC](859EEAFA)", "Chihiro", "859EEAFA")]
[TestCase("[Underwater]_Show_Title_-_12_(720p)_(5C7BC4F9)", "Underwater", "5C7BC4F9")]
[TestCase("[HorribleSubs]_Show_Title_-_33_[720p]", "HorribleSubs", "")]
[TestCase("[HorribleSubs] Show-Title - 13 [1080p].mkv", "HorribleSubs", "")]
[TestCase("[Doremi].Show.Title.5.Go.Go!.31.[1280x720].(C65D4B1F).mkv", "Doremi", "C65D4B1F")]
[TestCase("[Doremi].Show.Title.5.Go.Go!.31[1280x720].(C65D4B1F)", "Doremi", "C65D4B1F")]
[TestCase("[Doremi].Show.Title.5.Go.Go!.31.[1280x720].mkv", "Doremi", "")]
[TestCase("[K-F] Series Title 214", "K-F", "")]
[TestCase("[K-F] Series Title S10E14 214", "K-F", "")]
[TestCase("[K-F] Series Title 10x14 214", "K-F", "")]
[TestCase("[K-F] Series Title 214 10x14", "K-F", "")]
[TestCase("Series Title - 031 - The Resolution to Kill [Lunar].avi", "Lunar", "")]
[TestCase("[ACX]Series Title 01 Episode Name [Kosaka] (9C57891E).mkv", "ACX", "9C57891E")]
[TestCase("[S-T-D] Series Title! - 06 (1280x720 10bit AAC) (59B3F2EA).mkv", "S-T-D", "59B3F2EA")]
public void should_parse_releasegroup_and_hash(string postTitle, string subGroup, string hash)
{
var result = Parser.Parser.ParseTitle(postTitle);
result.Should().NotBeNull();
result.ReleaseGroup.Should().Be(subGroup);
result.ReleaseHash.Should().Be(hash);
}
[TestCase("[DHD] Series Title! - 08 (1280x720 10bit AAC) [8B00F2EA].mkv", "8B00F2EA")]
[TestCase("[DHD] Series Title! - 10 (1280x720 10bit AAC) [10BBF2EA].mkv", "10BBF2EA")]
[TestCase("[DHD] Series Title! - 08 (1280x720 10bit AAC) [008BF28B].mkv", "008BF28B")]
[TestCase("[DHD] Series Title! - 10 (1280x720 10bit AAC) [000BF10B].mkv", "000BF10B")]
[TestCase("[DHD] Series Title! - 08 (1280x720 8bit AAC) [8B8BF2EA].mkv", "8B8BF2EA")]
[TestCase("[DHD] Series Title! - 10 (1280x720 8bit AAC) [10B10BEA].mkv", "10B10BEA")]
public void should_parse_release_hashes_with_10b_or_8b(string postTitle, string hash)
{
var result = Parser.Parser.ParseTitle(postTitle);
result.Should().NotBeNull();
result.ReleaseHash.Should().Be(hash);
}
}
}

View File

@@ -441,6 +441,10 @@ namespace NzbDrone.Core.Test.ParserTests
[TestCase("Name (2020) - S01E20 - [AAC 2.0].testtitle.forced.fra.ass", new[] { "forced" }, "testtitle", "French")]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].fra.forced.testtitle.ass", new[] { "forced" }, "testtitle", "French")]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].forced.fra.testtitle.ass", new[] { "forced" }, "testtitle", "French")]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].ru-something-else.srt", new string[0], "something-else", "Russian")]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].Full Subtitles.eng.ass", new string[0], "Full Subtitles", "English")]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].mytitle - 1.en.ass", new string[0], "mytitle", "English")]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].mytitle.en.ass", new string[0], "mytitle", "English")]
public void should_parse_title_and_tags(string postTitle, string[] expectedTags, string expectedTitle, string expectedLanguage)
{
var subtitleTitleInfo = LanguageParser.ParseSubtitleLanguageInformation(postTitle);

View File

@@ -77,6 +77,7 @@ namespace NzbDrone.Core.Test.ParserTests
[TestCase("Series Title (S15E06-08) City Sushi", "Series Title", 15, new[] { 6, 7, 8 })]
[TestCase("Босх: Спадок (S2E1-4) / Series: Legacy (S2E1-4) (2023) WEB-DL 1080p Ukr/Eng | sub Eng", "Series: Legacy", 2, new[] { 1, 2, 3, 4 })]
[TestCase("Босх: Спадок / Series: Legacy / S2E1-4 of 10 (2023) WEB-DL 1080p Ukr/Eng | sub Eng", "Series: Legacy", 2, new[] { 1, 2, 3, 4 })]
[TestCase("Series Title - S26E96-97-98-99-100 - Episode 5931 + Episode 5932 + Episode 5933 + Episode 5934 + Episode 5935", "Series Title", 26, new[] { 96, 97, 98, 99, 100 })]
// [TestCase("", "", , new [] { })]
public void should_parse_multiple_episodes(string postTitle, string title, int season, int[] episodes)

View File

@@ -0,0 +1,23 @@
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.ParserTests
{
[TestFixture]
public class NormalizeEpisodeTitleFixture : CoreTest
{
[TestCase("Episode Title", "episode title")]
[TestCase("A.B,C;", "a b c")]
[TestCase("Episode Title", "episode title")]
[TestCase("French Title (1)", "french title")]
[TestCase("Series.Title.S01.Special.Episode.Title.720p.HDTV.x264-Sonarr", "episode title")]
[TestCase("Series.Title.S01E00.Episode.Title.720p.HDTV.x264-Sonarr", "episode title")]
public void should_normalize_episode_title(string input, string expected)
{
var result = Parser.Parser.NormalizeEpisodeTitle(input);
result.Should().Be(expected);
}
}
}

View File

@@ -6,7 +6,7 @@ using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.ParserTests
{
[TestFixture]
public class NormalizeTitleFixture : CoreTest
public class NormalizeSeriesTitleFixture : CoreTest
{
[TestCase("Series", "series")]
[TestCase("Series (2009)", "series2009")]

View File

@@ -29,6 +29,8 @@ namespace NzbDrone.Core.Test.ParserTests
[TestCase(@"C:\Test\Series\Season 01\1 Pilot (1080p HD).mkv", 1, 1)]
[TestCase(@"C:\Test\Series\Season 1\02 Honor Thy Father (1080p HD).m4v", 1, 2)]
[TestCase(@"C:\Test\Series\Season 1\2 Honor Thy Developer (1080p HD).m4v", 1, 2)]
[TestCase(@"C:\Test\Series\Season 2 - Total Series Action\01. Total Series Action - Episode 1 - Monster Cash.mkv", 2, 1)]
[TestCase(@"C:\Test\Series\Season 2\01. Total Series Action - Episode 1 - Monster Cash.mkv", 2, 1)]
// [TestCase(@"C:\series.state.S02E04.720p.WEB-DL.DD5.1.H.264\73696S02-04.mkv", 2, 4)] //Gets treated as S01E04 (because it gets parsed as anime); 2020-01 broken test case: Expected result.EpisodeNumbers to contain 1 item(s), but found 0
public void should_parse_from_path(string path, int season, int episode)
@@ -45,6 +47,7 @@ namespace NzbDrone.Core.Test.ParserTests
}
[TestCase("01-03\\The Series Title (2010) - 1x01-02-03 - Episode Title HDTV-720p Proper", "The Series Title (2010)", 1, new[] { 1, 2, 3 })]
[TestCase("Season 2\\E05-06 - Episode Title HDTV-720p Proper", "", 2, new[] { 5, 6 })]
public void should_parse_multi_episode_from_path(string path, string title, int season, int[] episodes)
{
var result = Parser.Parser.ParsePath(path.AsOsAgnostic());

View File

@@ -44,6 +44,7 @@ namespace NzbDrone.Core.Test.ParserTests
[TestCase("[Erai-raws] Series - 0955 ~ 1005 [1080p]", "Erai-raws")]
[TestCase("[Exiled-Destiny] Series Title", "Exiled-Destiny")]
[TestCase("Series.Title.S01E09.1080p.DSNP.WEB-DL.DDP2.0.H.264-VARYG", "VARYG")]
[TestCase("Stargate SG-1 (1997) - S01E01-02 - Children of the Gods (Showtime) (1080p.BD.DD5.1.x265-TheSickle[TAoE])", "TheSickle")]
// [TestCase("", "")]
public void should_parse_release_group(string title, string expected)

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