mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-04-17 21:26:13 -04:00
Compare commits
83 Commits
v4.0.2.126
...
v4.0.4.151
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9b013a8bf | ||
|
|
e966254462 | ||
|
|
016c4b353b | ||
|
|
d71c619f1a | ||
|
|
6c232b062c | ||
|
|
d6278fced4 | ||
|
|
317ce39aa2 | ||
|
|
941985f65b | ||
|
|
10daf97d81 | ||
|
|
6b08117d7d | ||
|
|
9afe1c4b3f | ||
|
|
0fdbbd018c | ||
|
|
8a7b67c593 | ||
|
|
4b8afe3d33 | ||
|
|
476e7a7b94 | ||
|
|
1fcd2b492c | ||
|
|
1aef91041e | ||
|
|
fc06e51352 | ||
|
|
f4c19a384b | ||
|
|
5061dc4b5e | ||
|
|
37863a8deb | ||
|
|
5c42935eb3 | ||
|
|
dac69445e4 | ||
|
|
aca10f6f4f | ||
|
|
74cdf01e49 | ||
|
|
a169ebff2a | ||
|
|
7fc3bebc91 | ||
|
|
e672996dbb | ||
|
|
238ba85f0a | ||
|
|
1562d3bae3 | ||
|
|
7776ec9955 | ||
|
|
af5a681ab7 | ||
|
|
0a7f3a12c2 | ||
|
|
2ef46e5b90 | ||
|
|
6003ca1696 | ||
|
|
0937ee6fef | ||
|
|
60ee7cc716 | ||
|
|
4e83820511 | ||
|
|
5a66b949cf | ||
|
|
f010f56290 | ||
|
|
060b789bc6 | ||
|
|
7353fe479d | ||
|
|
1ec1ce58e9 | ||
|
|
35d0e6a6f8 | ||
|
|
588372fd95 | ||
|
|
13c925b341 | ||
|
|
1335efd487 | ||
|
|
d338425951 | ||
|
|
fc6494c569 | ||
|
|
c403b2cdd5 | ||
|
|
cf3d51bab2 | ||
|
|
dec3fc6889 | ||
|
|
40bac23698 | ||
|
|
88de927435 | ||
|
|
29204c93a3 | ||
|
|
c641733781 | ||
|
|
58de0310fd | ||
|
|
172b1a82d1 | ||
|
|
e14568adef | ||
|
|
381ce61aef | ||
|
|
9f705e4161 | ||
|
|
063dba22a8 | ||
|
|
6d552f2a60 | ||
|
|
4d4d63921b | ||
|
|
6584d95331 | ||
|
|
86034beccd | ||
|
|
4aa56e3f91 | ||
|
|
2ec071a5ec | ||
|
|
d86aeb7472 | ||
|
|
48cb5d2271 | ||
|
|
a0329adeba | ||
|
|
89bef4af99 | ||
|
|
a12cdb34bc | ||
|
|
13e29bd257 | ||
|
|
61a7515041 | ||
|
|
2c25245860 | ||
|
|
18aadb544e | ||
|
|
c7dd7abf89 | ||
|
|
d0e9504af0 | ||
|
|
e81bb3b993 | ||
|
|
f211433b77 | ||
|
|
2068c5393e | ||
|
|
0183812cc5 |
13
.devcontainer/Sonarr.code-workspace
Normal file
13
.devcontainer/Sonarr.code-workspace
Normal file
@@ -0,0 +1,13 @@
|
||||
// This file is used to open the backend and frontend in the same workspace, which is necessary as
|
||||
// the frontend has vscode settings that are distinct from the backend
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": ".."
|
||||
},
|
||||
{
|
||||
"path": "../frontend"
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
19
.devcontainer/devcontainer.json
Normal file
19
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,19 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet
|
||||
{
|
||||
"name": "Sonarr",
|
||||
"image": "mcr.microsoft.com/devcontainers/dotnet:1-6.0",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"nodeGypDependencies": true,
|
||||
"version": "16",
|
||||
"nvmVersion": "latest"
|
||||
}
|
||||
},
|
||||
"forwardPorts": [8989],
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": ["esbenp.prettier-vscode"]
|
||||
}
|
||||
}
|
||||
}
|
||||
12
.github/dependabot.yml
vendored
Normal file
12
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for more information:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
# https://containers.dev/guide/dependabot
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "devcontainers"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
28
.github/workflows/build.yml
vendored
28
.github/workflows/build.yml
vendored
@@ -22,7 +22,7 @@ env:
|
||||
FRAMEWORK: net6.0
|
||||
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
|
||||
SONARR_MAJOR_VERSION: 4
|
||||
VERSION: 4.0.2
|
||||
VERSION: 4.0.4
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
@@ -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
|
||||
@@ -217,7 +217,7 @@ jobs:
|
||||
|
||||
deploy:
|
||||
if: ${{ github.ref_name == 'develop' || github.ref_name == 'main' }}
|
||||
needs: [backend, unit_test, unit_test_postgres, integration_test]
|
||||
needs: [backend, frontend, unit_test, unit_test_postgres, integration_test]
|
||||
secrets: inherit
|
||||
uses: ./.github/workflows/deploy.yml
|
||||
with:
|
||||
@@ -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, frontend, unit_test, unit_test_postgres, integration_test, deploy]
|
||||
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@v6.0.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' }}
|
||||
|
||||
24
.github/workflows/conflict_labeler.yml
vendored
Normal file
24
.github/workflows/conflict_labeler.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: Merge Conflict Labeler
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
pull_request_target:
|
||||
issue_comment:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
label:
|
||||
name: Labeling
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'Sonarr/Sonarr' }}
|
||||
steps:
|
||||
- name: Apply label
|
||||
uses: eps1lon/actions-label-merge-conflict@v3
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}}
|
||||
with:
|
||||
dirtyLabel: 'merge-conflict'
|
||||
repoToken: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
28
.github/workflows/deploy.yml
vendored
28
.github/workflows/deploy.yml
vendored
@@ -69,12 +69,38 @@ jobs:
|
||||
pattern: release_*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Get Previous Release
|
||||
id: previous-release
|
||||
uses: cardinalby/git-get-release-action@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
with:
|
||||
latest: true
|
||||
prerelease: ${{ inputs.branch != 'main' }}
|
||||
|
||||
- name: Generate Release Notes
|
||||
id: generate-release-notes
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
result-encoding: string
|
||||
script: |
|
||||
const { data } = await github.rest.repos.generateReleaseNotes({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: 'v${{ inputs.version }}',
|
||||
target_commitish: '${{ github.sha }}',
|
||||
previous_tag_name: '${{ steps.previous-release.outputs.tag_name }}',
|
||||
})
|
||||
return data.body
|
||||
|
||||
- name: Create release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
artifacts: _artifacts/Sonarr.*
|
||||
commit: ${{ github.sha }}
|
||||
generateReleaseNotes: true
|
||||
generateReleaseNotes: false
|
||||
body: ${{ steps.generate-release-notes.outputs.result }}
|
||||
name: ${{ inputs.version }}
|
||||
prerelease: ${{ inputs.branch != 'main' }}
|
||||
skipIfReleaseExists: true
|
||||
|
||||
2
.github/workflows/labeler.yml
vendored
2
.github/workflows/labeler.yml
vendored
@@ -9,4 +9,4 @@ jobs:
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v4
|
||||
- uses: actions/labeler@v5
|
||||
|
||||
12
.github/workflows/lock.yml
vendored
12
.github/workflows/lock.yml
vendored
@@ -9,13 +9,13 @@ jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v2
|
||||
- uses: dessant/lock-threads@v5
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-lock-inactive-days: '90'
|
||||
issue-exclude-created-before: ''
|
||||
issue-exclude-labels: 'one-day-maybe'
|
||||
issue-lock-labels: ''
|
||||
issue-lock-comment: ''
|
||||
issue-inactive-days: '90'
|
||||
exclude-issue-created-before: ''
|
||||
exclude-any-issue-labels: 'one-day-maybe'
|
||||
add-issue-labels: ''
|
||||
issue-comment: ''
|
||||
issue-lock-reason: 'resolved'
|
||||
process-only: ''
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -127,6 +127,7 @@ coverage*.xml
|
||||
coverage*.json
|
||||
setup/Output/
|
||||
*.~is
|
||||
.mono
|
||||
|
||||
#VS outout folders
|
||||
bin
|
||||
|
||||
7
.vscode/extensions.json
vendored
Normal file
7
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"esbenp.prettier-vscode",
|
||||
"ms-dotnettools.csdevkit",
|
||||
"ms-vscode-remote.remote-containers"
|
||||
]
|
||||
}
|
||||
26
.vscode/launch.json
vendored
Normal file
26
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
// Use IntelliSense to find out which attributes exist for C# debugging
|
||||
// Use hover for the description of the existing attributes
|
||||
// For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md
|
||||
"name": "Run Sonarr",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build dotnet",
|
||||
// If you have changed target frameworks, make sure to update the program path.
|
||||
"program": "${workspaceFolder}/_output/net6.0/Sonarr",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}",
|
||||
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
|
||||
"console": "integratedTerminal",
|
||||
"stopAtEntry": false
|
||||
},
|
||||
{
|
||||
"name": ".NET Core Attach",
|
||||
"type": "coreclr",
|
||||
"request": "attach"
|
||||
}
|
||||
]
|
||||
}
|
||||
44
.vscode/tasks.json
vendored
Normal file
44
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "build dotnet",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"msbuild",
|
||||
"-restore",
|
||||
"${workspaceFolder}/src/Sonarr.sln",
|
||||
"-p:GenerateFullPaths=true",
|
||||
"-p:Configuration=Debug",
|
||||
"-p:Platform=Posix",
|
||||
"-consoleloggerparameters:NoSummary;ForceNoAlign"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "publish",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"publish",
|
||||
"${workspaceFolder}/src/Sonarr.sln",
|
||||
"-property:GenerateFullPaths=true",
|
||||
"-consoleloggerparameters:NoSummary;ForceNoAlign"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "watch",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"watch",
|
||||
"run",
|
||||
"--project",
|
||||
"${workspaceFolder}/src/Sonarr.sln"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export interface AppSectionSaveState {
|
||||
|
||||
export interface PagedAppSectionState {
|
||||
pageSize: number;
|
||||
totalRecords?: number;
|
||||
}
|
||||
|
||||
export interface AppSectionFilterState<T> {
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface CommandBody {
|
||||
trigger: string;
|
||||
suppressMessages: boolean;
|
||||
seriesId?: number;
|
||||
seriesIds?: number[];
|
||||
seasonNumber?: number;
|
||||
}
|
||||
|
||||
interface Command extends ModelBase {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
.isDisabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
.dropdownArrowContainer {
|
||||
|
||||
@@ -22,6 +22,7 @@ import PasswordInput from './PasswordInput';
|
||||
import PathInputConnector from './PathInputConnector';
|
||||
import QualityProfileSelectInputConnector from './QualityProfileSelectInputConnector';
|
||||
import RootFolderSelectInputConnector from './RootFolderSelectInputConnector';
|
||||
import SeriesTagInput from './SeriesTagInput';
|
||||
import SeriesTypeSelectInput from './SeriesTypeSelectInput';
|
||||
import TagInputConnector from './TagInputConnector';
|
||||
import TagSelectInputConnector from './TagSelectInputConnector';
|
||||
@@ -87,6 +88,9 @@ function getComponent(type) {
|
||||
case inputTypes.DYNAMIC_SELECT:
|
||||
return EnhancedSelectInputConnector;
|
||||
|
||||
case inputTypes.SERIES_TAG:
|
||||
return SeriesTagInput;
|
||||
|
||||
case inputTypes.SERIES_TYPE_SELECT:
|
||||
return SeriesTypeSelectInput;
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -27,6 +27,8 @@ function getType({ type, selectOptionsProviderAction }) {
|
||||
return inputTypes.DYNAMIC_SELECT;
|
||||
}
|
||||
return inputTypes.SELECT;
|
||||
case 'seriesTag':
|
||||
return inputTypes.SERIES_TAG;
|
||||
case 'tag':
|
||||
return inputTypes.TEXT_TAG;
|
||||
case 'tagSelect':
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
53
frontend/src/Components/Form/SeriesTagInput.tsx
Normal file
53
frontend/src/Components/Form/SeriesTagInput.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import TagInputConnector from './TagInputConnector';
|
||||
|
||||
interface SeriesTageInputProps {
|
||||
name: string;
|
||||
value: number | number[];
|
||||
onChange: ({
|
||||
name,
|
||||
value,
|
||||
}: {
|
||||
name: string;
|
||||
value: number | number[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export default function SeriesTagInput(props: SeriesTageInputProps) {
|
||||
const { value, onChange, ...otherProps } = props;
|
||||
const isArray = Array.isArray(value);
|
||||
|
||||
const handleChange = useCallback(
|
||||
({ name, value: newValue }: { name: string; value: number[] }) => {
|
||||
if (isArray) {
|
||||
onChange({ name, value: newValue });
|
||||
} else {
|
||||
onChange({
|
||||
name,
|
||||
value: newValue.length ? newValue[newValue.length - 1] : 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
[isArray, onChange]
|
||||
);
|
||||
|
||||
let finalValue: number[] = [];
|
||||
|
||||
if (isArray) {
|
||||
finalValue = value;
|
||||
} else if (value === 0) {
|
||||
finalValue = [];
|
||||
} else {
|
||||
finalValue = [value];
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore 2786 'TagInputConnector' isn't typed yet
|
||||
<TagInputConnector
|
||||
{...otherProps}
|
||||
value={finalValue}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
1
frontend/src/Components/Modal/Modal.css.d.ts
vendored
1
frontend/src/Components/Modal/Modal.css.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"start_url": "../../../../",
|
||||
"theme_color": "#3a3f51",
|
||||
"background_color": "#3a3f51",
|
||||
"display": "minimal-ui"
|
||||
"display": "standalone"
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
17
frontend/src/Episode/getReleaseTypeName.ts
Normal file
17
frontend/src/Episode/getReleaseTypeName.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import ReleaseType from 'InteractiveImport/ReleaseType';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
export default function getReleaseTypeName(
|
||||
releaseType?: ReleaseType
|
||||
): string | null {
|
||||
switch (releaseType) {
|
||||
case 'singleEpisode':
|
||||
return translate('SingleEpisode');
|
||||
case 'multiEpisode':
|
||||
return translate('MultiEpisode');
|
||||
case 'seasonPack':
|
||||
return translate('SeasonPack');
|
||||
default:
|
||||
return translate('Unknown');
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import ReleaseType from 'InteractiveImport/ReleaseType';
|
||||
import Language from 'Language/Language';
|
||||
import { QualityModel } from 'Quality/Quality';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
@@ -17,6 +18,7 @@ export interface EpisodeFile extends ModelBase {
|
||||
quality: QualityModel;
|
||||
customFormats: CustomFormat[];
|
||||
indexerFlags: number;
|
||||
releaseType: ReleaseType;
|
||||
mediaInfo: MediaInfo;
|
||||
qualityCutoffNotMet: boolean;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export const LANGUAGE_SELECT = 'languageSelect';
|
||||
export const DOWNLOAD_CLIENT_SELECT = 'downloadClientSelect';
|
||||
export const ROOT_FOLDER_SELECT = 'rootFolderSelect';
|
||||
export const SELECT = 'select';
|
||||
export const SERIES_TAG = 'seriesTag';
|
||||
export const DYNAMIC_SELECT = 'dynamicSelect';
|
||||
export const SERIES_TYPE_SELECT = 'seriesTypeSelect';
|
||||
export const TAG = 'tag';
|
||||
@@ -45,6 +46,7 @@ export const all = [
|
||||
ROOT_FOLDER_SELECT,
|
||||
LANGUAGE_SELECT,
|
||||
SELECT,
|
||||
SERIES_TAG,
|
||||
DYNAMIC_SELECT,
|
||||
SERIES_TYPE_SELECT,
|
||||
TAG,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -36,6 +36,7 @@ import InteractiveImport, {
|
||||
import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal';
|
||||
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
|
||||
import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal';
|
||||
import SelectReleaseTypeModal from 'InteractiveImport/ReleaseType/SelectReleaseTypeModal';
|
||||
import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal';
|
||||
import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal';
|
||||
import Language from 'Language/Language';
|
||||
@@ -73,7 +74,8 @@ type SelectType =
|
||||
| 'releaseGroup'
|
||||
| 'quality'
|
||||
| 'language'
|
||||
| 'indexerFlags';
|
||||
| 'indexerFlags'
|
||||
| 'releaseType';
|
||||
|
||||
type FilterExistingFiles = 'all' | 'new';
|
||||
|
||||
@@ -128,6 +130,12 @@ const COLUMNS = [
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'releaseType',
|
||||
label: () => translate('ReleaseType'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'customFormats',
|
||||
label: React.createElement(Icon, {
|
||||
@@ -369,6 +377,10 @@ function InteractiveImportModalContent(
|
||||
key: 'indexerFlags',
|
||||
value: translate('SelectIndexerFlags'),
|
||||
},
|
||||
{
|
||||
key: 'releaseType',
|
||||
value: translate('SelectReleaseType'),
|
||||
},
|
||||
];
|
||||
|
||||
if (allowSeriesChange) {
|
||||
@@ -511,6 +523,7 @@ function InteractiveImportModalContent(
|
||||
languages,
|
||||
indexerFlags,
|
||||
episodeFileId,
|
||||
releaseType,
|
||||
} = item;
|
||||
|
||||
if (!series) {
|
||||
@@ -560,6 +573,7 @@ function InteractiveImportModalContent(
|
||||
quality,
|
||||
languages,
|
||||
indexerFlags,
|
||||
releaseType,
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -575,6 +589,7 @@ function InteractiveImportModalContent(
|
||||
quality,
|
||||
languages,
|
||||
indexerFlags,
|
||||
releaseType,
|
||||
downloadId,
|
||||
episodeFileId,
|
||||
});
|
||||
@@ -787,6 +802,22 @@ function InteractiveImportModalContent(
|
||||
[selectedIds, dispatch]
|
||||
);
|
||||
|
||||
const onReleaseTypeSelect = useCallback(
|
||||
(releaseType: string) => {
|
||||
dispatch(
|
||||
updateInteractiveImportItems({
|
||||
ids: selectedIds,
|
||||
releaseType,
|
||||
})
|
||||
);
|
||||
|
||||
dispatch(reprocessInteractiveImportItems({ ids: selectedIds }));
|
||||
|
||||
setSelectModalOpen(null);
|
||||
},
|
||||
[selectedIds, dispatch]
|
||||
);
|
||||
|
||||
const orderedSelectedIds = items.reduce((acc: number[], file) => {
|
||||
if (selectedIds.includes(file.id)) {
|
||||
acc.push(file.id);
|
||||
@@ -1000,6 +1031,14 @@ function InteractiveImportModalContent(
|
||||
onModalClose={onSelectModalClose}
|
||||
/>
|
||||
|
||||
<SelectReleaseTypeModal
|
||||
isOpen={selectModalOpen === 'releaseType'}
|
||||
releaseType="unknown"
|
||||
modalTitle={modalTitle}
|
||||
onReleaseTypeSelect={onReleaseTypeSelect}
|
||||
onModalClose={onSelectModalClose}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isConfirmDeleteModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
|
||||
@@ -12,6 +12,7 @@ import Episode from 'Episode/Episode';
|
||||
import EpisodeFormats from 'Episode/EpisodeFormats';
|
||||
import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
||||
import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||
import getReleaseTypeName from 'Episode/getReleaseTypeName';
|
||||
import IndexerFlags from 'Episode/IndexerFlags';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal';
|
||||
@@ -20,6 +21,8 @@ import SelectIndexerFlagsModal from 'InteractiveImport/IndexerFlags/SelectIndexe
|
||||
import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal';
|
||||
import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
|
||||
import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal';
|
||||
import ReleaseType from 'InteractiveImport/ReleaseType';
|
||||
import SelectReleaseTypeModal from 'InteractiveImport/ReleaseType/SelectReleaseTypeModal';
|
||||
import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal';
|
||||
import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal';
|
||||
import Language from 'Language/Language';
|
||||
@@ -44,7 +47,8 @@ type SelectType =
|
||||
| 'releaseGroup'
|
||||
| 'quality'
|
||||
| 'language'
|
||||
| 'indexerFlags';
|
||||
| 'indexerFlags'
|
||||
| 'releaseType';
|
||||
|
||||
type SelectedChangeProps = SelectStateInputProps & {
|
||||
hasEpisodeFileId: boolean;
|
||||
@@ -61,6 +65,7 @@ interface InteractiveImportRowProps {
|
||||
quality?: QualityModel;
|
||||
languages?: Language[];
|
||||
size: number;
|
||||
releaseType: ReleaseType;
|
||||
customFormats?: object[];
|
||||
customFormatScore?: number;
|
||||
indexerFlags: number;
|
||||
@@ -86,6 +91,7 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
|
||||
languages,
|
||||
releaseGroup,
|
||||
size,
|
||||
releaseType,
|
||||
customFormats,
|
||||
customFormatScore,
|
||||
indexerFlags,
|
||||
@@ -315,6 +321,27 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
|
||||
[id, dispatch, setSelectModalOpen, selectRowAfterChange]
|
||||
);
|
||||
|
||||
const onSelectReleaseTypePress = useCallback(() => {
|
||||
setSelectModalOpen('releaseType');
|
||||
}, [setSelectModalOpen]);
|
||||
|
||||
const onReleaseTypeSelect = useCallback(
|
||||
(releaseType: ReleaseType) => {
|
||||
dispatch(
|
||||
updateInteractiveImportItem({
|
||||
id,
|
||||
releaseType,
|
||||
})
|
||||
);
|
||||
|
||||
dispatch(reprocessInteractiveImportItems({ ids: [id] }));
|
||||
|
||||
setSelectModalOpen(null);
|
||||
selectRowAfterChange();
|
||||
},
|
||||
[id, dispatch, setSelectModalOpen, selectRowAfterChange]
|
||||
);
|
||||
|
||||
const onSelectIndexerFlagsPress = useCallback(() => {
|
||||
setSelectModalOpen('indexerFlags');
|
||||
}, [setSelectModalOpen]);
|
||||
@@ -461,6 +488,13 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
|
||||
|
||||
<TableRowCell>{formatBytes(size)}</TableRowCell>
|
||||
|
||||
<TableRowCellButton
|
||||
title={translate('ClickToChangeReleaseType')}
|
||||
onPress={onSelectReleaseTypePress}
|
||||
>
|
||||
{getReleaseTypeName(releaseType)}
|
||||
</TableRowCellButton>
|
||||
|
||||
<TableRowCell>
|
||||
{customFormats?.length ? (
|
||||
<Popover
|
||||
@@ -572,6 +606,14 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
|
||||
onModalClose={onSelectModalClose}
|
||||
/>
|
||||
|
||||
<SelectReleaseTypeModal
|
||||
isOpen={selectModalOpen === 'releaseType'}
|
||||
releaseType={releaseType ?? 'unknown'}
|
||||
modalTitle={modalTitle}
|
||||
onReleaseTypeSelect={onReleaseTypeSelect}
|
||||
onModalClose={onSelectModalClose}
|
||||
/>
|
||||
|
||||
<SelectIndexerFlagsModal
|
||||
isOpen={selectModalOpen === 'indexerFlags'}
|
||||
indexerFlags={indexerFlags ?? 0}
|
||||
|
||||
@@ -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';
|
||||
@@ -14,6 +15,7 @@ export interface InteractiveImportCommandOptions {
|
||||
quality: QualityModel;
|
||||
languages: Language[];
|
||||
indexerFlags: number;
|
||||
releaseType: ReleaseType;
|
||||
downloadId?: string;
|
||||
episodeFileId?: number;
|
||||
}
|
||||
@@ -33,6 +35,7 @@ interface InteractiveImport extends ModelBase {
|
||||
qualityWeight: number;
|
||||
customFormats: object[];
|
||||
indexerFlags: number;
|
||||
releaseType: ReleaseType;
|
||||
rejections: Rejection[];
|
||||
episodeFileId?: number;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
3
frontend/src/InteractiveImport/ReleaseType.ts
Normal file
3
frontend/src/InteractiveImport/ReleaseType.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
type ReleaseType = 'unknown' | 'singleEpisode' | 'multiEpisode' | 'seasonPack';
|
||||
|
||||
export default ReleaseType;
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import ReleaseType from 'InteractiveImport/ReleaseType';
|
||||
import SelectReleaseTypeModalContent from './SelectReleaseTypeModalContent';
|
||||
|
||||
interface SelectQualityModalProps {
|
||||
isOpen: boolean;
|
||||
releaseType: ReleaseType;
|
||||
modalTitle: string;
|
||||
onReleaseTypeSelect(releaseType: ReleaseType): void;
|
||||
onModalClose(): void;
|
||||
}
|
||||
|
||||
function SelectReleaseTypeModal(props: SelectQualityModalProps) {
|
||||
const { isOpen, releaseType, modalTitle, onReleaseTypeSelect, onModalClose } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<SelectReleaseTypeModalContent
|
||||
releaseType={releaseType}
|
||||
modalTitle={modalTitle}
|
||||
onReleaseTypeSelect={onReleaseTypeSelect}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectReleaseTypeModal;
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
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 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 ReleaseType from 'InteractiveImport/ReleaseType';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
const options = [
|
||||
{
|
||||
key: 'unknown',
|
||||
get value() {
|
||||
return translate('Unknown');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'singleEpisode',
|
||||
get value() {
|
||||
return translate('SingleEpisode');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'multiEpisode',
|
||||
get value() {
|
||||
return translate('MultiEpisode');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'seasonPack',
|
||||
get value() {
|
||||
return translate('SeasonPack');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
interface SelectReleaseTypeModalContentProps {
|
||||
releaseType: ReleaseType;
|
||||
modalTitle: string;
|
||||
onReleaseTypeSelect(releaseType: ReleaseType): void;
|
||||
onModalClose(): void;
|
||||
}
|
||||
|
||||
function SelectReleaseTypeModalContent(
|
||||
props: SelectReleaseTypeModalContentProps
|
||||
) {
|
||||
const { modalTitle, onReleaseTypeSelect, onModalClose } = props;
|
||||
const [releaseType, setReleaseType] = useState(props.releaseType);
|
||||
|
||||
const handleReleaseTypeChange = useCallback(
|
||||
({ value }: { value: string }) => {
|
||||
setReleaseType(value as ReleaseType);
|
||||
},
|
||||
[setReleaseType]
|
||||
);
|
||||
|
||||
const handleReleaseTypeSelect = useCallback(() => {
|
||||
onReleaseTypeSelect(releaseType);
|
||||
}, [releaseType, onReleaseTypeSelect]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{modalTitle} - {translate('SelectReleaseType')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Form>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ReleaseType')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="releaseType"
|
||||
value={releaseType}
|
||||
values={options}
|
||||
onChange={handleReleaseTypeChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<Button kind={kinds.SUCCESS} onPress={handleReleaseTypeSelect}>
|
||||
{translate('SelectReleaseType')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectReleaseTypeModalContent;
|
||||
@@ -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',
|
||||
|
||||
@@ -15,7 +15,7 @@ function SeasonInteractiveSearchModal(props) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
size={sizes.EXTRA_LARGE}
|
||||
size={sizes.EXTRA_EXTRA_LARGE}
|
||||
closeOnBackgroundClick={false}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
|
||||
@@ -151,6 +151,11 @@ class EditCustomFormatModalContent extends Component {
|
||||
</Form>
|
||||
|
||||
<FieldSet legend={translate('Conditions')}>
|
||||
<Alert kind={kinds.INFO}>
|
||||
<div>
|
||||
{translate('CustomFormatsSettingsTriggerInfo')}
|
||||
</div>
|
||||
</Alert>
|
||||
<div className={styles.customFormats}>
|
||||
{
|
||||
specifications.map((tag) => {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ const enableOptions = [
|
||||
get value() {
|
||||
return translate('NoChange');
|
||||
},
|
||||
disabled: true,
|
||||
isDisabled: true,
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -41,11 +42,6 @@ const COLUMNS = [
|
||||
},
|
||||
];
|
||||
|
||||
interface ImportListExclusionsProps {
|
||||
useCurrentPage: number;
|
||||
totalRecords: number;
|
||||
}
|
||||
|
||||
function createImportListExlucionsSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.settings.importListExclusions,
|
||||
@@ -57,8 +53,9 @@ function createImportListExlucionsSelector() {
|
||||
);
|
||||
}
|
||||
|
||||
function ImportListExclusions(props: ImportListExclusionsProps) {
|
||||
const { useCurrentPage, totalRecords } = props;
|
||||
function ImportListExclusions() {
|
||||
const history = useHistory();
|
||||
const useCurrentPage = history.action === 'POP';
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -155,6 +152,7 @@ function ImportListExclusions(props: ImportListExclusionsProps) {
|
||||
sortKey,
|
||||
error,
|
||||
sortDirection,
|
||||
totalRecords,
|
||||
...otherProps
|
||||
} = selected;
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ const autoAddOptions = [
|
||||
get value() {
|
||||
return translate('NoChange');
|
||||
},
|
||||
disabled: true,
|
||||
isDisabled: true,
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
|
||||
@@ -32,7 +32,7 @@ const enableOptions = [
|
||||
get value() {
|
||||
return translate('NoChange');
|
||||
},
|
||||
disabled: true,
|
||||
isDisabled: true,
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
|
||||
@@ -80,19 +80,19 @@ const fileNameTokens = [
|
||||
];
|
||||
|
||||
const seriesTokens = [
|
||||
{ token: '{Series Title}', example: 'The Series Title\'s!' },
|
||||
{ token: '{Series CleanTitle}', example: 'The Series Title\'s!' },
|
||||
{ token: '{Series TitleYear}', example: 'The Series Title\'s! (2010)' },
|
||||
{ token: '{Series CleanTitleYear}', example: 'The Series Title\'s! 2010' },
|
||||
{ token: '{Series TitleWithoutYear}', example: 'The Series Title\'s!' },
|
||||
{ token: '{Series CleanTitleWithoutYear}', example: 'The Series Title\'s!' },
|
||||
{ token: '{Series TitleThe}', example: 'Series Title\'s!, The' },
|
||||
{ token: '{Series CleanTitleThe}', example: 'Series Title\'s!, The' },
|
||||
{ token: '{Series TitleTheYear}', example: 'Series Title\'s!, The (2010)' },
|
||||
{ token: '{Series CleanTitleTheYear}', example: 'Series Title\'s!, The 2010' },
|
||||
{ token: '{Series TitleTheWithoutYear}', example: 'Series Title\'s!, The' },
|
||||
{ token: '{Series CleanTitleTheWithoutYear}', example: 'Series Title\'s!, The' },
|
||||
{ token: '{Series TitleFirstCharacter}', example: 'S' },
|
||||
{ token: '{Series Title}', example: 'The Series Title\'s!', footNote: 1 },
|
||||
{ token: '{Series CleanTitle}', example: 'The Series Title\'s!', footNote: 1 },
|
||||
{ token: '{Series TitleYear}', example: 'The Series Title\'s! (2010)', footNote: 1 },
|
||||
{ token: '{Series CleanTitleYear}', example: 'The Series Title\'s! 2010', footNote: 1 },
|
||||
{ token: '{Series TitleWithoutYear}', example: 'The Series Title\'s!', footNote: 1 },
|
||||
{ token: '{Series CleanTitleWithoutYear}', example: 'The Series Title\'s!', footNote: 1 },
|
||||
{ token: '{Series TitleThe}', example: 'Series Title\'s!, The', footNote: 1 },
|
||||
{ token: '{Series CleanTitleThe}', example: 'Series Title\'s!, The', footNote: 1 },
|
||||
{ token: '{Series TitleTheYear}', example: 'Series Title\'s!, The (2010)', footNote: 1 },
|
||||
{ token: '{Series CleanTitleTheYear}', example: 'Series Title\'s!, The 2010', footNote: 1 },
|
||||
{ token: '{Series TitleTheWithoutYear}', example: 'Series Title\'s!, The', footNote: 1 },
|
||||
{ token: '{Series CleanTitleTheWithoutYear}', example: 'Series Title\'s!, The', footNote: 1 },
|
||||
{ token: '{Series TitleFirstCharacter}', example: 'S', footNote: 1 },
|
||||
{ token: '{Series Year}', example: '2010' }
|
||||
];
|
||||
|
||||
@@ -124,8 +124,8 @@ const absoluteTokens = [
|
||||
];
|
||||
|
||||
const episodeTitleTokens = [
|
||||
{ token: '{Episode Title}', example: 'Episode\'s Title' },
|
||||
{ token: '{Episode CleanTitle}', example: 'Episodes Title' }
|
||||
{ token: '{Episode Title}', example: 'Episode\'s Title', footNote: 1 },
|
||||
{ token: '{Episode CleanTitle}', example: 'Episodes Title', footNote: 1 }
|
||||
];
|
||||
|
||||
const qualityTokens = [
|
||||
@@ -149,8 +149,13 @@ const mediaInfoTokens = [
|
||||
];
|
||||
|
||||
const otherTokens = [
|
||||
{ token: '{Release Group}', example: 'Rls Grp' },
|
||||
{ token: '{Custom Formats}', example: 'iNTERNAL' }
|
||||
{ token: '{Release Group}', example: 'Rls Grp', footNote: 1 },
|
||||
{ token: '{Custom Formats}', example: 'iNTERNAL' },
|
||||
{ token: '{Custom Format:FormatName}', example: 'AMZN' }
|
||||
];
|
||||
|
||||
const otherAnimeTokens = [
|
||||
{ token: '{Release Hash}', example: 'ABCDEFGH' }
|
||||
];
|
||||
|
||||
const originalTokens = [
|
||||
@@ -300,7 +305,7 @@ class NamingModal extends Component {
|
||||
<FieldSet legend={translate('Series')}>
|
||||
<div className={styles.groups}>
|
||||
{
|
||||
seriesTokens.map(({ token, example }) => {
|
||||
seriesTokens.map(({ token, example, footNote }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
@@ -308,6 +313,7 @@ class NamingModal extends Component {
|
||||
value={value}
|
||||
token={token}
|
||||
example={example}
|
||||
footNote={footNote}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={this.onOptionPress}
|
||||
@@ -317,6 +323,11 @@ class NamingModal extends Component {
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className={styles.footNote}>
|
||||
<Icon className={styles.icon} name={icons.FOOTNOTE} />
|
||||
<InlineMarkdown data={translate('SeriesFootNote')} />
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('SeriesID')}>
|
||||
@@ -446,7 +457,7 @@ class NamingModal extends Component {
|
||||
<FieldSet legend={translate('EpisodeTitle')}>
|
||||
<div className={styles.groups}>
|
||||
{
|
||||
episodeTitleTokens.map(({ token, example }) => {
|
||||
episodeTitleTokens.map(({ token, example, footNote }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
@@ -454,6 +465,7 @@ class NamingModal extends Component {
|
||||
value={value}
|
||||
token={token}
|
||||
example={example}
|
||||
footNote={footNote}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={this.onOptionPress}
|
||||
@@ -463,6 +475,10 @@ class NamingModal extends Component {
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className={styles.footNote}>
|
||||
<Icon className={styles.icon} name={icons.FOOTNOTE} />
|
||||
<InlineMarkdown data={translate('EpisodeTitleFootNote')} />
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('Quality')}>
|
||||
@@ -518,7 +534,26 @@ class NamingModal extends Component {
|
||||
<FieldSet legend={translate('Other')}>
|
||||
<div className={styles.groups}>
|
||||
{
|
||||
otherTokens.map(({ token, example }) => {
|
||||
otherTokens.map(({ token, example, footNote }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
name={name}
|
||||
value={value}
|
||||
token={token}
|
||||
example={example}
|
||||
footNote={footNote}
|
||||
tokenSeparator={tokenSeparator}
|
||||
tokenCase={tokenCase}
|
||||
onPress={this.onOptionPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
anime && otherAnimeTokens.map(({ token, example }) => {
|
||||
return (
|
||||
<NamingOption
|
||||
key={token}
|
||||
@@ -535,6 +570,11 @@ class NamingModal extends Component {
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className={styles.footNote}>
|
||||
<Icon className={styles.icon} name={icons.FOOTNOTE} />
|
||||
<InlineMarkdown data={translate('ReleaseGroupFootNote')} />
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('Original')}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,7 +4,6 @@ import translate from 'Utilities/String/translate';
|
||||
import styles from './TheTvdb.css';
|
||||
|
||||
function TheTvdb(props) {
|
||||
debugger;
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<img
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function TagInUse(props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (count > 1 && labelPlural ) {
|
||||
if (count > 1 && labelPlural) {
|
||||
return (
|
||||
<div>
|
||||
{count} {labelPlural.toLowerCase()}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import $ from 'jquery';
|
||||
import _ from 'lodash';
|
||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
||||
import getProviderState from 'Utilities/State/getProviderState';
|
||||
import { set } from '../baseActions';
|
||||
|
||||
const abortCurrentRequests = {};
|
||||
let lastTestData = null;
|
||||
|
||||
export function createCancelTestProviderHandler(section) {
|
||||
return function(getState, payload, dispatch) {
|
||||
@@ -17,10 +20,25 @@ function createTestProviderHandler(section, url) {
|
||||
return function(getState, payload, dispatch) {
|
||||
dispatch(set({ section, isTesting: true }));
|
||||
|
||||
const testData = getProviderState(payload, getState, section);
|
||||
const {
|
||||
queryParams = {},
|
||||
...otherPayload
|
||||
} = payload;
|
||||
|
||||
const testData = getProviderState({ ...otherPayload }, getState, section);
|
||||
const params = { ...queryParams };
|
||||
|
||||
// If the user is re-testing the same provider without changes
|
||||
// force it to be tested.
|
||||
|
||||
if (_.isEqual(testData, lastTestData)) {
|
||||
params.forceTest = true;
|
||||
}
|
||||
|
||||
lastTestData = testData;
|
||||
|
||||
const ajaxOptions = {
|
||||
url: `${url}/test`,
|
||||
url: `${url}/test?${$.param(params, true)}`,
|
||||
method: 'POST',
|
||||
contentType: 'application/json',
|
||||
dataType: 'json',
|
||||
@@ -32,6 +50,8 @@ function createTestProviderHandler(section, url) {
|
||||
abortCurrentRequests[section] = abortRequest;
|
||||
|
||||
request.done((data) => {
|
||||
lastTestData = null;
|
||||
|
||||
dispatch(set({
|
||||
section,
|
||||
isTesting: false,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createAction } from 'redux-actions';
|
||||
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 createSetTableOptionReducer from 'Store/Actions/Creators/Reducers/createSetTableOptionReducer';
|
||||
import { createThunk, handleThunks } from 'Store/thunks';
|
||||
import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
|
||||
import createServerSideCollectionHandlers from '../Creators/createServerSideCollectionHandlers';
|
||||
import createSetTableOptionReducer from '../Creators/Reducers/createSetTableOptionReducer';
|
||||
|
||||
//
|
||||
// Variables
|
||||
|
||||
@@ -163,6 +163,7 @@ export const actionHandlers = handleThunks({
|
||||
languages: item.languages,
|
||||
releaseGroup: item.releaseGroup,
|
||||
indexerFlags: item.indexerFlags,
|
||||
releaseType: item.releaseType,
|
||||
downloadId: item.downloadId
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
];
|
||||
|
||||
//
|
||||
|
||||
23
frontend/src/Store/Selectors/createMultiSeriesSelector.ts
Normal file
23
frontend/src/Store/Selectors/createMultiSeriesSelector.ts
Normal 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;
|
||||
@@ -10,15 +10,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.commandName {
|
||||
display: inline-block;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.userAgent {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
.queued,
|
||||
.started,
|
||||
.ended {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
238
frontend/src/System/Tasks/Queued/QueuedTaskRow.tsx
Normal file
238
frontend/src/System/Tasks/Queued/QueuedTaskRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,8 @@
|
||||
.commandName {
|
||||
display: inline-block;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.userAgent {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
8
frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.css.d.ts
vendored
Normal file
8
frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.css.d.ts
vendored
Normal 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;
|
||||
57
frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.tsx
Normal file
57
frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
74
frontend/src/System/Tasks/Queued/QueuedTasks.tsx
Normal file
74
frontend/src/System/Tasks/Queued/QueuedTasks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -175,16 +175,46 @@
|
||||
</Otherwise>
|
||||
</Choose>
|
||||
|
||||
<!--
|
||||
Set architecture to RuntimeInformation.ProcessArchitecture if not specified -->
|
||||
<Choose>
|
||||
<When Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture)' == 'X64'">
|
||||
<PropertyGroup>
|
||||
<Architecture>x64</Architecture>
|
||||
</PropertyGroup>
|
||||
</When>
|
||||
<When Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture)' == 'X86'">
|
||||
<PropertyGroup>
|
||||
<Architecture>x86</Architecture>
|
||||
</PropertyGroup>
|
||||
</When>
|
||||
<When Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture)' == 'Arm64'">
|
||||
<PropertyGroup>
|
||||
<Architecture>arm64</Architecture>
|
||||
</PropertyGroup>
|
||||
</When>
|
||||
<When Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture)' == 'Arm'">
|
||||
<PropertyGroup>
|
||||
<Architecture>arm</Architecture>
|
||||
</PropertyGroup>
|
||||
</When>
|
||||
<Otherwise>
|
||||
<PropertyGroup>
|
||||
<Architecture></Architecture>
|
||||
</PropertyGroup>
|
||||
</Otherwise>
|
||||
</Choose>
|
||||
|
||||
<PropertyGroup Condition="'$(IsWindows)' == 'true' and
|
||||
'$(RuntimeIdentifier)' == ''">
|
||||
<_UsingDefaultRuntimeIdentifier>true</_UsingDefaultRuntimeIdentifier>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<RuntimeIdentifier>win-$(Architecture)</RuntimeIdentifier>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(IsLinux)' == 'true' and
|
||||
'$(RuntimeIdentifier)' == ''">
|
||||
<_UsingDefaultRuntimeIdentifier>true</_UsingDefaultRuntimeIdentifier>
|
||||
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
|
||||
<RuntimeIdentifier>linux-$(Architecture)</RuntimeIdentifier>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(IsOSX)' == 'true' and
|
||||
|
||||
@@ -18,6 +18,8 @@ namespace NzbDrone.Common.Test.InstrumentationTests
|
||||
[TestCase(@"https://baconbits.org/feeds.php?feed=torrents_tv&user=12345&auth=2b51db35e1910123321025a12b9933d2&passkey=mySecret&authkey=2b51db35e1910123321025a12b9933d2")]
|
||||
[TestCase(@"http://127.0.0.1:9117/dl/indexername?jackett_apikey=flwjiefewklfjacketmySecretsdfldskjfsdlk&path=we0re9f0sdfbase64sfdkfjsdlfjk&file=The+Torrent+File+Name.torrent")]
|
||||
[TestCase(@"http://nzb.su/getnzb/2b51db35e1912ffc138825a12b9933d2.nzb&i=37292&r=2b51db35e1910123321025a12b9933d2")]
|
||||
[TestCase(@"https://b-hd.me/torrent/download/auto.343756.is1t1pl127p1sfwur8h4kgyhg1wcsn05")]
|
||||
[TestCase(@"https://b-hd.me/torrent/download/a-slug-in-the-url.343756.is1t1pl127p1sfwur8h4kgyhg1wcsn05")]
|
||||
|
||||
// NzbGet
|
||||
[TestCase(@"{ ""Name"" : ""ControlUsername"", ""Value"" : ""mySecret"" }, { ""Name"" : ""ControlPassword"", ""Value"" : ""mySecret"" }, ")]
|
||||
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
@@ -30,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;
|
||||
@@ -43,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)
|
||||
@@ -249,19 +255,27 @@ namespace NzbDrone.Common.Http.Dispatchers
|
||||
return _credentialCache.Get("credentialCache", () => new CredentialCache());
|
||||
}
|
||||
|
||||
private static bool HasRoutableIPv4Address()
|
||||
private bool HasRoutableIPv4Address()
|
||||
{
|
||||
// Get all IPv4 addresses from all interfaces and return true if there are any with non-loopback addresses
|
||||
var networkInterfaces = NetworkInterface.GetAllNetworkInterfaces();
|
||||
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)));
|
||||
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 static async ValueTask<Stream> onConnect(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
|
||||
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.
|
||||
@@ -285,7 +299,9 @@ namespace NzbDrone.Common.Http.Dispatchers
|
||||
catch
|
||||
{
|
||||
// Do not retry IPv6 if a routable IPv4 address is available, otherwise continue to attempt IPv6 connections.
|
||||
useIPv6 = !HasRoutableIPv4Address();
|
||||
var routableIPv4 = HasRoutableIPv4Address();
|
||||
_logger.Info("IPv4 is available: {0}, IPv6 will be {1}", routableIPv4, routableIPv4 ? "disabled" : "left enabled");
|
||||
useIPv6 = !routableIPv4;
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ namespace NzbDrone.Common.Instrumentation
|
||||
new (@"/fetch/[a-z0-9]{32}/(?<secret>[a-z0-9]{32})", RegexOptions.Compiled),
|
||||
new (@"getnzb.*?(?<=\?|&)(r)=(?<secret>[^&=]+?)(?= |&|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
|
||||
new (@"\b(\w*)?(_?(?<!use|get_)token|username|passwo?rd)=(?<secret>[^&=]+?)(?= |&|$|;)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
|
||||
new (@"-hd.me/torrent/[a-z0-9-]\.[0-9]+\.(?<secret>[0-9a-z]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
|
||||
|
||||
// Trackers Announce Keys; Designed for Qbit Json; should work for all in theory
|
||||
new (@"announce(\.php)?(/|%2f|%3fpasskey%3d)(?<secret>[a-z0-9]{16,})|(?<secret>[a-z0-9]{16,})(/|%2f)announce", RegexOptions.Compiled | RegexOptions.IgnoreCase),
|
||||
|
||||
@@ -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="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" />
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Collections.Generic;
|
||||
using FizzWare.NBuilder;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.AutoTagging;
|
||||
using NzbDrone.Core.AutoTagging.Specifications;
|
||||
using NzbDrone.Core.Housekeeping.Housekeepers;
|
||||
using NzbDrone.Core.Profiles.Releases;
|
||||
using NzbDrone.Core.Tags;
|
||||
@@ -45,5 +48,35 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers
|
||||
Subject.Clean();
|
||||
AllStoredModels.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_delete_used_auto_tagging_tag_specification_tags()
|
||||
{
|
||||
var tags = Builder<Tag>
|
||||
.CreateListOfSize(2)
|
||||
.All()
|
||||
.With(x => x.Id = 0)
|
||||
.BuildList();
|
||||
Db.InsertMany(tags);
|
||||
|
||||
var autoTags = Builder<AutoTag>.CreateListOfSize(1)
|
||||
.All()
|
||||
.With(x => x.Id = 0)
|
||||
.With(x => x.Specifications = new List<IAutoTaggingSpecification>
|
||||
{
|
||||
new TagSpecification
|
||||
{
|
||||
Name = "Test",
|
||||
Value = tags[0].Id
|
||||
}
|
||||
})
|
||||
.BuildList();
|
||||
|
||||
Mocker.GetMock<IAutoTaggingRepository>().Setup(s => s.All())
|
||||
.Returns(autoTags);
|
||||
|
||||
Subject.Clean();
|
||||
AllStoredModels.Should().HaveCount(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NzbDrone.Core.Indexers;
|
||||
using NzbDrone.Core.Validation;
|
||||
|
||||
@@ -12,5 +13,7 @@ namespace NzbDrone.Core.Test.IndexerTests
|
||||
}
|
||||
|
||||
public string BaseUrl { get; set; }
|
||||
|
||||
public IEnumerable<int> MultiLanguages { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,14 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Aggregation.Aggregators
|
||||
[TestFixture]
|
||||
public class AggregateSubtitleInfoFixture : CoreTest<AggregateSubtitleInfo>
|
||||
{
|
||||
[TestCase("Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "", "Name (2020) - S01E20 - [AAC 2.0].default.eng.forced.ass")]
|
||||
[TestCase("Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "", "Name (2020) - S01E20 - [AAC 2.0].eng.default.ass")]
|
||||
[TestCase("Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "", "Name (2020) - S01E20 - [AAC 2.0].fra.ass")]
|
||||
[TestCase("", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "Name (2020) - S01E20 - [AAC 2.0].default.eng.forced.ass")]
|
||||
[TestCase("", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "Name (2020) - S01E20 - [AAC 2.0].eng.default.ass")]
|
||||
[TestCase("", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "Name (2020) - S01E20 - [AAC 2.0].fra.ass")]
|
||||
public void should_do_basic_parse(string relativePath, string originalFilePath, string path)
|
||||
[TestCase("Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "", "Name (2020) - S01E20 - [AAC 2.0].default.eng.forced.ass", null)]
|
||||
[TestCase("Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "", "Name (2020) - S01E20 - [AAC 2.0].eng.default.ass", null)]
|
||||
[TestCase("Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "", "Name (2020) - S01E20 - [AAC 2.0].fra.ass", null)]
|
||||
[TestCase("Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 5.1].mkv", "", "Name (2020) - S01E20 - [FLAC 2.0].fra.ass", "Name (2020)/Season 1/Name (2020) - S01E20 - [FLAC 2.0].mkv")]
|
||||
[TestCase("", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "Name (2020) - S01E20 - [AAC 2.0].default.eng.forced.ass", null)]
|
||||
[TestCase("", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "Name (2020) - S01E20 - [AAC 2.0].eng.default.ass", null)]
|
||||
[TestCase("", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "Name (2020) - S01E20 - [AAC 2.0].fra.ass", null)]
|
||||
public void should_do_basic_parse(string relativePath, string originalFilePath, string path, string fileNameBeforeRename)
|
||||
{
|
||||
var episodeFile = new EpisodeFile
|
||||
{
|
||||
@@ -23,7 +24,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Aggregation.Aggregators
|
||||
OriginalFilePath = originalFilePath
|
||||
};
|
||||
|
||||
var subtitleTitleInfo = Subject.CleanSubtitleTitleInfo(episodeFile, path);
|
||||
var subtitleTitleInfo = Subject.CleanSubtitleTitleInfo(episodeFile, path, fileNameBeforeRename);
|
||||
|
||||
subtitleTitleInfo.Title.Should().BeNull();
|
||||
subtitleTitleInfo.Copy.Should().Be(0);
|
||||
@@ -40,7 +41,7 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Aggregation.Aggregators
|
||||
RelativePath = relativePath
|
||||
};
|
||||
|
||||
var subtitleTitleInfo = Subject.CleanSubtitleTitleInfo(episodeFile, path);
|
||||
var subtitleTitleInfo = Subject.CleanSubtitleTitleInfo(episodeFile, path, null);
|
||||
|
||||
subtitleTitleInfo.LanguageTags.Should().NotContain("default");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -132,6 +132,9 @@ 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("Series_Title_2_[01]_[AniLibria_TV]_[WEBRip_1080p]", "Series Title 2", 1, 0, 0)]
|
||||
|
||||
// [TestCase("", "", 0, 0, 0)]
|
||||
public void should_parse_absolute_numbers(string postTitle, string title, int absoluteEpisodeNumber, int seasonNumber, int episodeNumber)
|
||||
@@ -177,6 +180,7 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
[TestCase("[Erai-raws] Series-Title! 2 - 01~10 [1080p][Multiple Subtitle]", "Series-Title! 2", 1, 10)]
|
||||
[TestCase("[Erai-raws] Series Title! - 01 ~ 10 [1080p][Multiple Subtitle]", "Series Title!", 1, 10)]
|
||||
[TestCase("[Erai-raws] Series-Title! 2 - 01 ~ 10 [1080p][Multiple Subtitle]", "Series-Title! 2", 1, 10)]
|
||||
[TestCase("Series_Title_2_[01-05]_[AniLibria_TV]_[WEBRip_1080p]", "Series Title 2", 1, 5)]
|
||||
|
||||
// [TestCase("", "", 1, 2)]
|
||||
public void should_parse_multi_episode_absolute_numbers(string postTitle, string title, int firstAbsoluteEpisodeNumber, int lastAbsoluteEpisodeNumber)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,6 +429,26 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
result.Languages.Should().Contain(Language.English);
|
||||
}
|
||||
|
||||
[TestCase("Остання серія (Сезон 1) / The Last Series (Season 1) (2024) WEB-DLRip-AVC 2xUkr/Eng | Sub Ukr/Eng")]
|
||||
[TestCase("Справжня серія (Сезон 1-3) / True Series (Season 1-3) (2014-2019) BDRip-AVC 3xUkr/Eng | Ukr/Eng")]
|
||||
[TestCase("Серія (Сезон 1-3) / The Series (Seasons 1-3) (2019-2022) BDRip-AVC 4xUkr/Eng | Sub 2xUkr/Eng")]
|
||||
public void should_parse_english_and_ukranian(string postTitle)
|
||||
{
|
||||
var result = Parser.Parser.ParseTitle(postTitle);
|
||||
result.Languages.Count.Should().Be(2);
|
||||
result.Languages.Should().Contain(Language.Ukrainian);
|
||||
result.Languages.Should().Contain(Language.English);
|
||||
}
|
||||
|
||||
[TestCase("Серія (Сезон 1, серії 01-26 із 51) / Seri (Season 1, episodes 01-26) (2018) WEBRip-AVC 2Ukr/Tur")]
|
||||
public void should_parse_turkish_and_ukranian(string postTitle)
|
||||
{
|
||||
var result = Parser.Parser.ParseTitle(postTitle);
|
||||
result.Languages.Count.Should().Be(2);
|
||||
result.Languages.Should().Contain(Language.Ukrainian);
|
||||
result.Languages.Should().Contain(Language.Turkish);
|
||||
}
|
||||
|
||||
[TestCase("Name (2020) - S01E20 - [AAC 2.0].testtitle.default.eng.forced.ass", new[] { "default", "forced" }, "testtitle", "English")]
|
||||
[TestCase("Name (2020) - S01E20 - [AAC 2.0].eng.default.testtitle.forced.ass", new[] { "default", "forced" }, "testtitle", "English")]
|
||||
[TestCase("Name (2020) - S01E20 - [AAC 2.0].default.eng.testtitle.forced.ass", new[] { "default", "forced" }, "testtitle", "English")]
|
||||
@@ -444,6 +464,7 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
[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 1.en.ass", new string[0], "mytitle 1", "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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -29,6 +29,10 @@ 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:\Test\Series\Season 1\02.04.24 - S01E01 - The Rabbit Hole", 1, 1)]
|
||||
[TestCase(@"C:\Test\Series\Season 1\8 Series Rules - S01E01 - Pilot", 1, 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 +49,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());
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -85,7 +85,8 @@ namespace NzbDrone.Core.Annotations
|
||||
Device,
|
||||
TagSelect,
|
||||
RootFolder,
|
||||
QualityProfile
|
||||
QualityProfile,
|
||||
SeriesTag
|
||||
}
|
||||
|
||||
public enum HiddenType
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using FluentValidation;
|
||||
using NzbDrone.Core.Annotations;
|
||||
using NzbDrone.Core.Tv;
|
||||
using NzbDrone.Core.Validation;
|
||||
|
||||
namespace NzbDrone.Core.AutoTagging.Specifications
|
||||
{
|
||||
public class TagSpecificationValidator : AbstractValidator<TagSpecification>
|
||||
{
|
||||
public TagSpecificationValidator()
|
||||
{
|
||||
RuleFor(c => c.Value).GreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
public class TagSpecification : AutoTaggingSpecificationBase
|
||||
{
|
||||
private static readonly TagSpecificationValidator Validator = new ();
|
||||
|
||||
public override int Order => 1;
|
||||
public override string ImplementationName => "Tag";
|
||||
|
||||
[FieldDefinition(1, Label = "AutoTaggingSpecificationTag", Type = FieldType.SeriesTag)]
|
||||
public int Value { get; set; }
|
||||
|
||||
protected override bool IsSatisfiedByWithoutNegate(Series series)
|
||||
{
|
||||
return series.Tags.Contains(Value);
|
||||
}
|
||||
|
||||
public override NzbDroneValidationResult Validate()
|
||||
{
|
||||
return new NzbDroneValidationResult(Validator.Validate(this));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NzbDrone.Core.Datastore;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.IO;
|
||||
using Dapper;
|
||||
using FluentMigrator;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.Datastore.Migration.Framework;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
|
||||
namespace NzbDrone.Core.Datastore.Migration
|
||||
{
|
||||
[Migration(204)]
|
||||
public class add_add_release_hash : NzbDroneMigrationBase
|
||||
{
|
||||
protected override void MainDbUpgrade()
|
||||
{
|
||||
Alter.Table("EpisodeFiles").AddColumn("ReleaseHash").AsString().Nullable();
|
||||
|
||||
Execute.WithConnection(UpdateEpisodeFiles);
|
||||
}
|
||||
|
||||
private void UpdateEpisodeFiles(IDbConnection conn, IDbTransaction tran)
|
||||
{
|
||||
var updates = new List<object>();
|
||||
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.Transaction = tran;
|
||||
cmd.CommandText = "SELECT \"Id\", \"SceneName\", \"RelativePath\", \"OriginalFilePath\" FROM \"EpisodeFiles\"";
|
||||
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
var id = reader.GetInt32(0);
|
||||
var sceneName = reader[1] as string;
|
||||
var relativePath = reader[2] as string;
|
||||
var originalFilePath = reader[3] as string;
|
||||
|
||||
ParsedEpisodeInfo parsedEpisodeInfo = null;
|
||||
|
||||
var originalTitle = sceneName;
|
||||
|
||||
if (originalTitle.IsNullOrWhiteSpace() && originalFilePath.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
originalTitle = Path.GetFileNameWithoutExtension(originalFilePath);
|
||||
}
|
||||
|
||||
if (originalTitle.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
parsedEpisodeInfo = Parser.Parser.ParseTitle(originalTitle);
|
||||
}
|
||||
|
||||
if (parsedEpisodeInfo == null || parsedEpisodeInfo.ReleaseHash.IsNullOrWhiteSpace())
|
||||
{
|
||||
parsedEpisodeInfo = Parser.Parser.ParseTitle(Path.GetFileNameWithoutExtension(relativePath));
|
||||
}
|
||||
|
||||
if (parsedEpisodeInfo != null && parsedEpisodeInfo.ReleaseHash.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
updates.Add(new
|
||||
{
|
||||
Id = id,
|
||||
ReleaseHash = parsedEpisodeInfo.ReleaseHash
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.Count > 0)
|
||||
{
|
||||
var updateEpisodeFilesSql = "UPDATE \"EpisodeFiles\" SET \"ReleaseHash\" = @ReleaseHash WHERE \"Id\" = @Id";
|
||||
conn.Execute(updateEpisodeFilesSql, updates, transaction: tran);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -388,16 +388,20 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
|
||||
}
|
||||
}
|
||||
|
||||
var minimumRetention = 60 * 24 * 14;
|
||||
|
||||
return new DownloadClientInfo
|
||||
{
|
||||
IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost",
|
||||
OutputRootFolders = new List<OsPath> { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, destDir) },
|
||||
RemovesCompletedDownloads = (config.MaxRatioEnabled || (config.MaxSeedingTimeEnabled && config.MaxSeedingTime < minimumRetention)) && (config.MaxRatioAction == QBittorrentMaxRatioAction.Remove || config.MaxRatioAction == QBittorrentMaxRatioAction.DeleteFiles)
|
||||
RemovesCompletedDownloads = RemovesCompletedDownloads(config)
|
||||
};
|
||||
}
|
||||
|
||||
private bool RemovesCompletedDownloads(QBittorrentPreferences config)
|
||||
{
|
||||
var minimumRetention = 60 * 24 * 14; // 14 days in minutes
|
||||
return (config.MaxRatioEnabled || (config.MaxSeedingTimeEnabled && config.MaxSeedingTime < minimumRetention)) && (config.MaxRatioAction == QBittorrentMaxRatioAction.Remove || config.MaxRatioAction == QBittorrentMaxRatioAction.DeleteFiles);
|
||||
}
|
||||
|
||||
protected override void Test(List<ValidationFailure> failures)
|
||||
{
|
||||
failures.AddIfNotNull(TestConnection());
|
||||
@@ -448,7 +452,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
|
||||
|
||||
// Complain if qBittorrent is configured to remove torrents on max ratio
|
||||
var config = Proxy.GetConfig(Settings);
|
||||
if ((config.MaxRatioEnabled || config.MaxSeedingTimeEnabled) && (config.MaxRatioAction == QBittorrentMaxRatioAction.Remove || config.MaxRatioAction == QBittorrentMaxRatioAction.DeleteFiles))
|
||||
if (RemovesCompletedDownloads(config))
|
||||
{
|
||||
return new NzbDroneValidationFailure(string.Empty, _localizationService.GetLocalizedString("DownloadClientQbittorrentValidationRemovesAtRatioLimit"))
|
||||
{
|
||||
|
||||
@@ -139,12 +139,14 @@ namespace NzbDrone.Core.Download.Clients.RTorrent
|
||||
// Ignore torrents with an empty path
|
||||
if (torrent.Path.IsNullOrWhiteSpace())
|
||||
{
|
||||
_logger.Warn("Torrent '{0}' has an empty download path and will not be processed. Adjust this to an absolute path in rTorrent", torrent.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (torrent.Path.StartsWith("."))
|
||||
{
|
||||
throw new DownloadClientException("Download paths must be absolute. Please specify variable \"directory\" in rTorrent.");
|
||||
_logger.Warn("Torrent '{0}' has a download path starting with '.' and will not be processed. Adjust this to an absolute path in rTorrent", torrent.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
var item = new DownloadClientItem();
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace NzbDrone.Core.Extras
|
||||
{
|
||||
public interface IExistingExtraFiles
|
||||
{
|
||||
List<string> ImportExtraFiles(Series series, List<string> possibleExtraFiles);
|
||||
List<string> ImportExtraFiles(Series series, List<string> possibleExtraFiles, string fileNameBeforeRename);
|
||||
}
|
||||
|
||||
public class ExistingExtraFileService : IExistingExtraFiles, IHandle<SeriesScannedEvent>
|
||||
@@ -25,7 +25,7 @@ namespace NzbDrone.Core.Extras
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public List<string> ImportExtraFiles(Series series, List<string> possibleExtraFiles)
|
||||
public List<string> ImportExtraFiles(Series series, List<string> possibleExtraFiles, string fileNameBeforeRename)
|
||||
{
|
||||
_logger.Debug("Looking for existing extra files in {0}", series.Path);
|
||||
|
||||
@@ -33,7 +33,7 @@ namespace NzbDrone.Core.Extras
|
||||
|
||||
foreach (var existingExtraFileImporter in _existingExtraFileImporters)
|
||||
{
|
||||
var imported = existingExtraFileImporter.ProcessFiles(series, possibleExtraFiles, importedFiles);
|
||||
var imported = existingExtraFileImporter.ProcessFiles(series, possibleExtraFiles, importedFiles, fileNameBeforeRename);
|
||||
|
||||
importedFiles.AddRange(imported.Select(f => Path.Combine(series.Path, f.RelativePath)));
|
||||
}
|
||||
@@ -45,7 +45,7 @@ namespace NzbDrone.Core.Extras
|
||||
{
|
||||
var series = message.Series;
|
||||
var possibleExtraFiles = message.PossibleExtraFiles;
|
||||
var importedFiles = ImportExtraFiles(series, possibleExtraFiles);
|
||||
var importedFiles = ImportExtraFiles(series, possibleExtraFiles, null);
|
||||
|
||||
_logger.Info("Found {0} possible extra files, imported {1} files.", possibleExtraFiles.Count, importedFiles.Count);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user