1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-17 21:26:13 -04:00

Compare commits

...

86 Commits

Author SHA1 Message Date
Weblate
200396ef7a Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translation: Servarr/Sonarr
2024-01-31 19:39:13 -08:00
Stevie Robinson
c5a724f14e New: Send 'On Manual Interaction Required' notifications in more cases
Closes #6448
2024-01-31 19:38:51 -08:00
Mark McDowall
42b11528b4 New: Improve multi-language negate Custom Format
Closes #6408
2024-01-31 19:37:44 -08:00
Alex Herbig
e2210228b3 New: Add RZeroX to release group parsing exceptions 2024-01-31 19:36:39 -08:00
Bogdan
ded7c3c6e2 Only bind shortcut for pending changes confirmation when it's shown 2024-01-31 19:36:21 -08:00
Stevie Robinson
e1c6722aad New: Ignore 'Other' subfolder when scanning disk
Closes #6437
2024-01-31 19:35:21 -08:00
Bogdan
e17655c26a Fixed: Notifications with only On Series Add enabled being labeled as disabled 2024-01-31 19:34:51 -08:00
Stevie Robinson
e66c628241 Update some translation keys 2024-01-31 19:34:17 -08:00
Mark McDowall
8f0514a91d Fixed: Grouped calendar events not correctly showing as downloading
Closes #6441
2024-01-31 19:33:46 -08:00
bakerboy448
d7aea82e45 Improve Release Grabbing & Failure Logging 2024-01-31 19:33:38 -08:00
Mark McDowall
19db75b36b Add max token length (including ellipsis) for some tokens
New: Accept ':##' on renaming tokens to allow specifying a maximum length for series, episode titles and release group
Closes #6416
2024-01-31 19:33:21 -08:00
Weblate
11a18b534a Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Crocmou <slaanesh8854@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Lars <lars.erik.heloe@gmail.com>
Co-authored-by: Magyar <kochnorbert@icloud.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Stas Panasiuk <temnyip@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: resi23 <x-resistant-x@gmx.de>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nb_NO/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/uk/
Translation: Servarr/Sonarr
2024-01-31 19:33:13 -08:00
Sonarr
70807a9dcf Automated API Docs update
ignore-downstream
2024-01-26 22:09:26 -08:00
Weblate
350600607d Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Alexander <a.burdun@gmail.com>
Co-authored-by: Crocmou <slaanesh8854@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Magyar <kochnorbert@icloud.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: diaverso <alexito_perez.95@hotmail.com>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: reloxx <reloxx@interia.pl>
Co-authored-by: zichichi <sollami@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/uk/
Translation: Servarr/Sonarr
2024-01-26 22:09:16 -08:00
Mark McDowall
e9f0c96249 Fixed: Specials not allowing multi-episode select in Manual Import
Closes #6429
2024-01-26 22:01:04 -08:00
ta264
d9acbf5682 Fixed: FolderWritable check for CIFS shares mounted in Unix
This reverts commit 8c892a732ed57af9bb1f39743e0c16361f41b50f.

(cherry picked from commit 96384521c59233dab5bd8289e7c84043f75b84a2)
2024-01-26 22:00:50 -08:00
Stevie Robinson
07cbd7c8d2 Fixed: Validating DownloadStation output path
Closes #6421
2024-01-27 00:59:43 -05:00
Mark McDowall
0ea189d03c Fixed: History retention for Newsbin 2024-01-26 21:56:13 -08:00
Bogdan
9e3f9f9618 Fixed: Testing for disabled Notifications 2024-01-26 21:56:05 -08:00
The Dark
68c326ae27 New: Import list clean library option
Closes #5201
2024-01-27 00:55:52 -05:00
Weblate
46367d2023 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Dani Talens <databio@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Magyar <kochnorbert@icloud.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: wilfriedarma <wilfriedarma.collet@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translation: Servarr/Sonarr
2024-01-26 21:54:57 -08:00
Sonarr
b64c52a846 Automated API Docs update
ignore-downstream
2024-01-22 20:59:24 -08:00
Mark McDowall
345854d0fe New: Optionally remove from queue by changing category to 'Post-Import Category' when configured
Closes #6023
2024-01-22 23:56:35 -05:00
Bogdan
31baed4b2c Fixed: Sorting by name in Manage Indexer and Download Client modals 2024-01-22 20:56:01 -08:00
Bogdan
7d0d503a5e New: Display database migration version in Status 2024-01-22 20:55:53 -08:00
Stevie Robinson
9f50166fa6 Fixed: Regular Expression Custom Format translation 2024-01-22 23:55:33 -05:00
Bogdan
3c1ca6ea4e New: Expand seasons with all episodes having missing air dates 2024-01-22 20:54:35 -08:00
Mark McDowall
3cd4c67ba1 New: Add download client name to pending items waiting for a specific client
Closes #6274
2024-01-22 20:52:01 -08:00
Mark McDowall
fc3a2e9ab2 New: Added some extra pixels to grouped calendar events
Closes #6395
2024-01-22 20:51:53 -08:00
Mark McDowall
a71d40edba New: Add recycle bin path for deleted episodes to webhook/custom script
Closes #6114
2024-01-22 20:51:38 -08:00
Mark McDowall
9ba5850fca Fixed: Parsing Hungarian anime releases
Closes #6275
2024-01-22 20:51:27 -08:00
Mark McDowall
0d06418194 New: Add size to more history events
Closes #6234
2024-01-22 20:51:19 -08:00
Mark McDowall
f95dd00b51 Fixed: Migrating subtitle files with unexpectedly large number at end
Closes #6409
2024-01-22 20:50:43 -08:00
Bogdan
271266b10a Fix possible NullRef in Email Encryption migration 2024-01-22 20:50:34 -08:00
Mark McDowall
cab93249ec Fixed: Number only hashes getting substituted incorrectly 2024-01-20 16:44:12 -08:00
Mark McDowall
8921c5d7a0 Fixed: Subtitle title migration when original title is null 2024-01-20 16:43:53 -08:00
Weblate
dbbf1a7f58 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Magyar <kochnorbert@icloud.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translation: Servarr/Sonarr
2024-01-20 16:43:40 -08:00
Jendrik Weise
69f99373e5 New: Parse subtitle titles
Closes #5955
2024-01-20 15:19:33 -08:00
Mark McDowall
7be5732a3a New: Option to disable Email encryption
Closes #6380
2024-01-20 15:18:26 -08:00
Mark McDowall
e66ba84fc0 New: Log warning if less than 1 GB free space during update
Closes #6385
2024-01-20 15:18:06 -08:00
Mark McDowall
c0b30a5028 Fixed: Series poster view on mobile devices
Closes #6387
2024-01-20 15:17:55 -08:00
Bogdan
3cf4d2907e Transpile logical assignment operators with babel 2024-01-20 15:17:42 -08:00
Weblate
ae96ebca57 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Bastián Quezada <baskezada@gmail.com>
Co-authored-by: Blair Noctis <fqmxz5hyfba7ft85@neon.casa>
Co-authored-by: Dani Talens <databio@gmail.com>
Co-authored-by: Deleted User <noreply+2593@weblate.org>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Julian Baquero <julian-baquero@upc.edu.co>
Co-authored-by: Koch Norbert <kochnorbert@icloud.com>
Co-authored-by: MaddionMax <kovacs.tamas@ius.hu>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: brn <barantsenkul@gmail.com>
Co-authored-by: resi23 <x-resistant-x@gmx.de>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pl/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2024-01-20 15:17:33 -08:00
Mark McDowall
d336aaf3f0 Fixed: Don't clone indexer API Key
Closes #6265
2024-01-19 21:30:34 -08:00
bakerboy448
ec40bc6eea Improve Release Title Custom Format debugging
Towards #5598
2024-01-19 21:30:24 -08:00
Mark McDowall
75bb34afaa Bump version to 4.0.1 2024-01-19 19:26:38 -08:00
Stevie Robinson
de1cc25c90 Translate backend: Autotagging + CF specs, Metadata + ImportLists
Signed-off-by: Stevie Robinson <stevie.robinson@gmail.com>
2024-01-18 21:47:03 -08:00
Mark McDowall
9884f6f282 Fixed: Icons on full color calendar events
Closes #6331
2024-01-19 00:43:51 -05:00
Qstick
e792db4d33 New: Improve All Series call by using dictionary for stats iteration 2024-01-18 21:43:34 -08:00
Bogdan
2dbf5b5a71 Check Content-Type in FileList parser 2024-01-18 21:43:17 -08:00
Stevie Robinson
c6bb6ad878 Round off the seeded ratio when checking for removal candidates
Signed-off-by: Stevie Robinson <stevie.robinson@gmail.com>
2024-01-18 21:43:07 -08:00
Bogdan
bfd24da2d9 Fixed: Importing Plex RSS lists with invalid items (#6374) 2024-01-19 00:42:51 -05:00
Stevie Robinson
8dd3b45c90 New: Drop commands table content before postgres migration
Signed-off-by: Stevie Robinson <stevie.robinson@gmail.com>
2024-01-19 00:42:31 -05:00
Stevie Robinson
0b0f21d0ac Update install.sh to not prompt for package installation
Script will exit without input if a prereq package is missing
2024-01-18 21:41:38 -08:00
Sonarr
738f5c58ad Automated API Docs update
ignore-downstream
2024-01-18 21:41:30 -08:00
Weblate
9a7b5bf14e Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Blair Noctis <fqmxz5hyfba7ft85@neon.casa>
Co-authored-by: Dani Talens <databio@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Petr Vojar <vojar.petr@outlook.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: crayon3shawn <crayon3shawn@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_TW/
Translation: Servarr/Sonarr
2024-01-18 21:41:14 -08:00
Mark McDowall
e1260d504e Re-enable deploy 2024-01-15 21:55:35 -08:00
Sonarr
736651324f Automated API Docs update
ignore-downstream
2024-01-15 21:55:22 -08:00
Bogdan
489f03441b Fixed: Filter history by multiple event types 2024-01-16 00:54:27 -05:00
Stevie Robinson
e4b5d559df Sort Custom Filters
Closes #6334
2024-01-16 00:53:21 -05:00
Stevie Robinson
07fbb0d1f4 Add missing translation keys from Indexer Settings
Signed-off-by: Stevie Robinson <stevie.robinson@gmail.com>
2024-01-16 00:52:55 -05:00
Stevie Robinson
666455f9b1 New: Add 'zhtw' and 'yue' language codes as Chinese language
Closes #6363
2024-01-16 00:52:40 -05:00
Bogdan
091449d9bf Throw download as failed for invalid magnet links 2024-01-15 21:51:17 -08:00
Qstick
f87a66fcba Improved http timeout handling 2024-01-15 21:51:02 -08:00
Blair Noctis
1bba7e177b Fixed: Improve help text for download client priority
Closes #6270
2024-01-16 00:50:25 -05:00
Rubicj
57445bbe57 New: Added column in Queue
Closes #6270
2024-01-16 00:28:28 -05:00
Qstick
ec91142c85 Fixed: Only use frames for Primary video stream for analysis
(cherry picked from commit 581828b0dcfcd4aa1ae581b899f812071457c9ca)
2024-01-15 23:03:46 -06:00
Mark McDowall
0685896ed8 Fixed: Prevent selecting season or episode in Manual Import if series or episode is not selected
Closes #6354
2024-01-14 12:37:33 -08:00
Mark McDowall
ee0048c768 Fixed: Reprocessing multi-language file in Manual Import 2024-01-14 12:37:33 -08:00
bakerboy448
16e5ffa467 Update logging to indicate a hardlink is being attempted 2024-01-14 15:16:53 -05:00
Mark McDowall
431c66c7c1 Update PR Template
[skip ci]
2024-01-14 12:12:30 -08:00
Mark McDowall
57bd6539c8 Update bug_report.yml
[skip ci]
2024-01-14 12:44:37 -05:00
Mark McDowall
637cb1711d Update PR template
[skip ci]
2024-01-14 12:43:14 -05:00
Weblate
7e011df2b2 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Daniele Prevedello <dprevedello86@gmail.com>
Co-authored-by: DimitriDR <dimitridroeck@gmail.com>
Co-authored-by: Fixer <ygj59783@zslsz.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Watashi <drazy24@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: hansaudun <hans@n5.no>
Co-authored-by: hcharbonnier <hugues.charbonnier@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nb_NO/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2024-01-13 17:07:06 -08:00
Stevie Robinson
79907c881c Add: New icon for deleted episodes with status missing from disk
Signed-off-by: Stevie Robinson <stevie.robinson@gmail.com>
2024-01-13 16:35:13 -08:00
Mark McDowall
db6a627983 Don't write global.json while updating API docs 2024-01-13 16:33:17 -08:00
Weblate
d76a489be6 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Fixer <ygj59783@zslsz.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Watashi <drazy24@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2024-01-13 16:33:01 -08:00
Mark McDowall
fd17df0dd0 New: Optional directory setting for Aria2
Closes #6343
2024-01-13 16:32:09 -08:00
Mark McDowall
53cf530893 Fixed: Series posters flickering when width changes repeatedly
Closes #6311
2024-01-13 16:31:58 -08:00
Mark McDowall
91f33c670e Fix post-build test reporting and report summary 2024-01-13 11:32:01 -08:00
Mark McDowall
d322619733 Temporarily disable deploy 2024-01-12 17:45:32 -08:00
Mark McDowall
1182798929 Actually run Windows integration tests 2024-01-12 17:45:32 -08:00
Mark McDowall
ad0249c7db Use publish-unit-test-result-action for test result reporting 2024-01-12 17:45:32 -08:00
Mark McDowall
3259e6dc10 Download artifacts for Publish Test Results workflow 2024-01-12 17:45:32 -08:00
Mark McDowall
541d3307e1 Don't use TestCase for single test 2024-01-12 17:45:32 -08:00
Mark McDowall
ad60352bae Ignore CA1825 2024-01-12 17:45:32 -08:00
267 changed files with 6841 additions and 1334 deletions

View File

@@ -203,6 +203,7 @@ dotnet_diagnostic.CA1819.severity = suggestion
dotnet_diagnostic.CA1822.severity = suggestion
dotnet_diagnostic.CA1823.severity = suggestion
dotnet_diagnostic.CA1824.severity = suggestion
dotnet_diagnostic.CA1825.severity = suggestion
dotnet_diagnostic.CA2000.severity = suggestion
dotnet_diagnostic.CA2002.severity = suggestion
dotnet_diagnostic.CA2007.severity = suggestion

View File

@@ -1,5 +1,5 @@
name: Bug Report
description: 'Support Requests will be closed immediately, if you are not 100% certain this is a bug please go to our Reddit, Discord, Forums, or IRC first. Only bug reports for v4 will be accepted, older versions are EOL & unsupported.'
description: 'Only bug reports for v4 will be accepted, older versions are no longer receiving bug fixes and support issues will be closed immediately.'
labels: ['needs-triage']
body:
- type: checkboxes

View File

@@ -1,14 +1,15 @@
#### Database Migration
YES | NO
#### Description
A few sentences describing the overall goals of the pull request's commits.
#### Todos
- [ ] Tests
- [ ] Wiki Updates
<!-- Remove any of the following sections if they are not used -->
#### Screenshots for UI Changes
#### Database Migration
YES - ###
#### Issues Fixed or Closed by this PR
* Closes #
*

View File

@@ -77,11 +77,20 @@ runs:
- name: Run tests
shell: bash
run: dotnet test ./_tests/Sonarr.*.Test.dll --filter "${{ inputs.filter }}" --logger trx --results-directory "${{ env.RESULTS_NAME }}"
run: dotnet test ./_tests/Sonarr.*.Test.dll --filter "${{ inputs.filter }}" --logger "trx;LogFileName=${{ env.RESULTS_NAME }}.trx"
- name: Upload Test Results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: results-${{ env.RESULTS_NAME }}
path: ${{ env.RESULTS_NAME }}/*.trx
path: TestResults/*.trx
- name: Publish Test Results
uses: phoenix-actions/test-reporting@v12
with:
name: Test Results
output-to: step-summary
path: '*.trx'
reporter: dotnet-trx
working-directory: TestResults

View File

@@ -31,12 +31,6 @@ jobs:
- name: Setup dotnet
uses: actions/setup-dotnet@v3
id: setup-dotnet
with:
dotnet-version: '6.0.x'
- name: Create temporary global.json
run: |
echo '{"sdk":{"version": "${{ steps.setup-dotnet.outputs.dotnet-version }}" } }' > ./global.json
- name: Create openapi.json
run: ./docs.sh Linux

View File

@@ -17,7 +17,7 @@ env:
FRAMEWORK: net6.0
BRANCH: ${{ github.head_ref || github.ref_name }}
SONARR_MAJOR_VERSION: 4
VERSION: 4.0.0
VERSION: 4.0.1
jobs:
backend:
@@ -188,7 +188,7 @@ jobs:
binary_path: osx-x64/${{ needs.backend.outputs.framework }}/Sonarr
- os: windows-latest
artifact: tests-win-x64
filter: TestCategory!=ManualTest&TestCategory=WINDOWS&TestCategory=IntegrationTest
filter: TestCategory!=ManualTest&TestCategory!=LINUX&TestCategory=IntegrationTest
binary_artifact: build_windows
binary_path: win-x64/${{ needs.backend.outputs.framework }}/Sonarr
runs-on: ${{ matrix.os }}

View File

@@ -13,12 +13,29 @@ permissions:
jobs:
report:
if: ${{ github.event.workflow_run.conclusion != 'skipped' && github.event.workflow_run.conclusion != 'cancelled' }}
runs-on: ubuntu-latest
steps:
- name: Check out
uses: actions/checkout@v3
- name: Download Test Reports
uses: actions/download-artifact@v4
with:
path: test-results
pattern: results-*
merge-multiple: true
repository: ${{ github.event.repository.owner.login }}/${{ github.event.repository.name }}
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Test Results
uses: phoenix-actions/test-reporting@v12
with:
artifact: /results-(.*)/
name: '$1'
list-suites: failed
list-tests: failed
name: Test Results
only-summary: true
path: '*.trx'
reporter: dotnet-trx
working-directory: test-results

View File

@@ -98,7 +98,7 @@ echo "Directories created"
echo ""
echo "Installing pre-requisite Packages"
# shellcheck disable=SC2086
apt update && apt install $app_prereq
apt update && apt install -y $app_prereq
echo ""
ARCH=$(dpkg --print-architecture)
# get arch

View File

@@ -2,6 +2,8 @@ const loose = true;
module.exports = {
plugins: [
'@babel/plugin-transform-logical-assignment-operators',
// Stage 1
'@babel/plugin-proposal-export-default-from',
['@babel/plugin-transform-optional-chaining', { loose }],

View File

@@ -6,7 +6,7 @@ import { icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './HistoryEventTypeCell.css';
function getIconName(eventType) {
function getIconName(eventType, data) {
switch (eventType) {
case 'grabbed':
return icons.DOWNLOADING;
@@ -17,7 +17,7 @@ function getIconName(eventType) {
case 'downloadFailed':
return icons.DOWNLOADING;
case 'episodeFileDeleted':
return icons.DELETE;
return data.reason === 'MissingFromDisk' ? icons.FILE_MISSING : icons.DELETE;
case 'episodeFileRenamed':
return icons.ORGANIZE;
case 'downloadIgnored':
@@ -47,7 +47,7 @@ function getTooltip(eventType, data) {
case 'downloadFailed':
return translate('DownloadFailedEpisodeTooltip');
case 'episodeFileDeleted':
return translate('EpisodeFileDeletedTooltip');
return data.reason === 'MissingFromDisk' ? translate('EpisodeFileMissingTooltip') : translate('EpisodeFileDeletedTooltip');
case 'episodeFileRenamed':
return translate('EpisodeFileRenamedTooltip');
case 'downloadIgnored':
@@ -58,7 +58,7 @@ function getTooltip(eventType, data) {
}
function HistoryEventTypeCell({ eventType, data }) {
const iconName = getIconName(eventType);
const iconName = getIconName(eventType, data);
const iconKind = getIconKind(eventType);
const tooltip = getTooltip(eventType, data);

View File

@@ -25,7 +25,7 @@ import toggleSelected from 'Utilities/Table/toggleSelected';
import QueueFilterModal from './QueueFilterModal';
import QueueOptionsConnector from './QueueOptionsConnector';
import QueueRowConnector from './QueueRowConnector';
import RemoveQueueItemsModal from './RemoveQueueItemsModal';
import RemoveQueueItemModal from './RemoveQueueItemModal';
class Queue extends Component {
@@ -305,9 +305,16 @@ class Queue extends Component {
}
</PageContentBody>
<RemoveQueueItemsModal
<RemoveQueueItemModal
isOpen={isConfirmRemoveModalOpen}
selectedCount={selectedCount}
canChangeCategory={isConfirmRemoveModalOpen && (
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);
return !!(item && item.downloadClientHasPostImportCategory);
})
)}
canIgnore={isConfirmRemoveModalOpen && (
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);
@@ -315,7 +322,7 @@ class Queue extends Component {
return !!(item && item.seriesId && item.episodeId);
})
)}
allPending={isConfirmRemoveModalOpen && (
pending={isConfirmRemoveModalOpen && (
selectedIds.every((id) => {
const item = items.find((i) => i.id === id);

View File

@@ -99,7 +99,9 @@ class QueueRow extends Component {
indexer,
outputPath,
downloadClient,
downloadClientHasPostImportCategory,
estimatedCompletionTime,
added,
timeleft,
size,
sizeleft,
@@ -362,6 +364,15 @@ class QueueRow extends Component {
);
}
if (name === 'added') {
return (
<RelativeDateCellConnector
key={name}
date={added}
/>
);
}
if (name === 'actions') {
return (
<TableRowCell
@@ -410,6 +421,7 @@ class QueueRow extends Component {
<RemoveQueueItemModal
isOpen={isRemoveQueueItemModalOpen}
sourceTitle={title}
canChangeCategory={!!downloadClientHasPostImportCategory}
canIgnore={!!series}
isPending={isPending}
onRemovePress={this.onRemoveQueueItemModalConfirmed}
@@ -440,7 +452,9 @@ QueueRow.propTypes = {
indexer: PropTypes.string,
outputPath: PropTypes.string,
downloadClient: PropTypes.string,
downloadClientHasPostImportCategory: PropTypes.bool,
estimatedCompletionTime: PropTypes.string,
added: PropTypes.string,
timeleft: PropTypes.string,
size: PropTypes.number,
sizeleft: PropTypes.number,

View File

@@ -1,171 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
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 Modal from 'Components/Modal/Modal';
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, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
class RemoveQueueItemModal extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
remove: true,
blocklist: false,
skipRedownload: false
};
}
//
// Control
resetState = function() {
this.setState({
remove: true,
blocklist: false,
skipRedownload: false
});
};
//
// Listeners
onRemoveChange = ({ value }) => {
this.setState({ remove: value });
};
onBlocklistChange = ({ value }) => {
this.setState({ blocklist: value });
};
onSkipRedownloadChange = ({ value }) => {
this.setState({ skipRedownload: value });
};
onRemoveConfirmed = () => {
const state = this.state;
this.resetState();
this.props.onRemovePress(state);
};
onModalClose = () => {
this.resetState();
this.props.onModalClose();
};
//
// Render
render() {
const {
isOpen,
sourceTitle,
canIgnore,
isPending
} = this.props;
const { remove, blocklist, skipRedownload } = this.state;
return (
<Modal
isOpen={isOpen}
size={sizes.MEDIUM}
onModalClose={this.onModalClose}
>
<ModalContent
onModalClose={this.onModalClose}
>
<ModalHeader>
{translate('RemoveQueueItem', { sourceTitle })}
</ModalHeader>
<ModalBody>
<div>
{translate('RemoveQueueItemConfirmation', { sourceTitle })}
</div>
{
isPending ?
null :
<FormGroup>
<FormLabel>{translate('RemoveFromDownloadClient')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="remove"
value={remove}
helpTextWarning={translate('RemoveFromDownloadClientHelpTextWarning')}
isDisabled={!canIgnore}
onChange={this.onRemoveChange}
/>
</FormGroup>
}
<FormGroup>
<FormLabel>{translate('BlocklistRelease')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="blocklist"
value={blocklist}
helpText={translate('BlocklistReleaseSearchEpisodeAgainHelpText')}
onChange={this.onBlocklistChange}
/>
</FormGroup>
{
blocklist ?
<FormGroup>
<FormLabel>{translate('SkipRedownload')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="skipRedownload"
value={skipRedownload}
helpText={translate('SkipRedownloadHelpText')}
onChange={this.onSkipRedownloadChange}
/>
</FormGroup> :
null
}
</ModalBody>
<ModalFooter>
<Button onPress={this.onModalClose}>
{translate('Close')}
</Button>
<Button
kind={kinds.DANGER}
onPress={this.onRemoveConfirmed}
>
{translate('Remove')}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
}
RemoveQueueItemModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
sourceTitle: PropTypes.string.isRequired,
canIgnore: PropTypes.bool.isRequired,
isPending: PropTypes.bool.isRequired,
onRemovePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default RemoveQueueItemModal;

View File

@@ -0,0 +1,230 @@
import React, { useCallback, useMemo, useState } from 'react';
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 Modal from 'Components/Modal/Modal';
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, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './RemoveQueueItemModal.css';
interface RemovePressProps {
remove: boolean;
changeCategory: boolean;
blocklist: boolean;
skipRedownload: boolean;
}
interface RemoveQueueItemModalProps {
isOpen: boolean;
sourceTitle: string;
canChangeCategory: boolean;
canIgnore: boolean;
isPending: boolean;
selectedCount?: number;
onRemovePress(props: RemovePressProps): void;
onModalClose: () => void;
}
type RemovalMethod = 'removeFromClient' | 'changeCategory' | 'ignore';
type BlocklistMethod =
| 'doNotBlocklist'
| 'blocklistAndSearch'
| 'blocklistOnly';
function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
const {
isOpen,
sourceTitle,
canIgnore,
canChangeCategory,
isPending,
selectedCount,
onRemovePress,
onModalClose,
} = props;
const multipleSelected = selectedCount && selectedCount > 1;
const [removalMethod, setRemovalMethod] =
useState<RemovalMethod>('removeFromClient');
const [blocklistMethod, setBlocklistMethod] =
useState<BlocklistMethod>('doNotBlocklist');
const { title, message } = useMemo(() => {
if (!selectedCount) {
return {
title: translate('RemoveQueueItem', { sourceTitle }),
message: translate('RemoveQueueItemConfirmation', { sourceTitle }),
};
}
if (selectedCount === 1) {
return {
title: translate('RemoveSelectedItem'),
message: translate('RemoveSelectedItemQueueMessageText'),
};
}
return {
title: translate('RemoveSelectedItems'),
message: translate('RemoveSelectedItemsQueueMessageText', {
selectedCount,
}),
};
}, [sourceTitle, selectedCount]);
const removalMethodOptions = useMemo(() => {
return [
{
key: 'removeFromClient',
value: translate('RemoveFromDownloadClient'),
hint: multipleSelected
? translate('RemoveMultipleFromDownloadClientHint')
: translate('RemoveFromDownloadClientHint'),
},
{
key: 'changeCategory',
value: translate('ChangeCategory'),
isDisabled: !canChangeCategory,
hint: multipleSelected
? translate('ChangeCategoryMultipleHint')
: translate('ChangeCategoryHint'),
},
{
key: 'ignore',
value: multipleSelected
? translate('IgnoreDownloads')
: translate('IgnoreDownload'),
isDisabled: !canIgnore,
hint: multipleSelected
? translate('IgnoreDownloadsHint')
: translate('IgnoreDownloadHint'),
},
];
}, [canChangeCategory, canIgnore, multipleSelected]);
const blocklistMethodOptions = useMemo(() => {
return [
{
key: 'doNotBlocklist',
value: translate('DoNotBlocklist'),
hint: translate('DoNotBlocklistHint'),
},
{
key: 'blocklistAndSearch',
value: translate('BlocklistAndSearch'),
hint: multipleSelected
? translate('BlocklistAndSearchMultipleHint')
: translate('BlocklistAndSearchHint'),
},
{
key: 'blocklistOnly',
value: translate('BlocklistOnly'),
hint: multipleSelected
? translate('BlocklistMultipleOnlyHint')
: translate('BlocklistOnlyHint'),
},
];
}, [multipleSelected]);
const handleRemovalMethodChange = useCallback(
({ value }: { value: RemovalMethod }) => {
setRemovalMethod(value);
},
[setRemovalMethod]
);
const handleBlocklistMethodChange = useCallback(
({ value }: { value: BlocklistMethod }) => {
setBlocklistMethod(value);
},
[setBlocklistMethod]
);
const handleConfirmRemove = useCallback(() => {
onRemovePress({
remove: removalMethod === 'removeFromClient',
changeCategory: removalMethod === 'changeCategory',
blocklist: blocklistMethod !== 'doNotBlocklist',
skipRedownload: blocklistMethod === 'blocklistOnly',
});
setRemovalMethod('removeFromClient');
setBlocklistMethod('doNotBlocklist');
}, [
removalMethod,
blocklistMethod,
setRemovalMethod,
setBlocklistMethod,
onRemovePress,
]);
const handleModalClose = useCallback(() => {
setRemovalMethod('removeFromClient');
setBlocklistMethod('doNotBlocklist');
onModalClose();
}, [setRemovalMethod, setBlocklistMethod, onModalClose]);
return (
<Modal isOpen={isOpen} size={sizes.MEDIUM} onModalClose={handleModalClose}>
<ModalContent onModalClose={handleModalClose}>
<ModalHeader>{title}</ModalHeader>
<ModalBody>
<div className={styles.message}>{message}</div>
{isPending ? null : (
<FormGroup>
<FormLabel>{translate('RemoveQueueItemRemovalMethod')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="removalMethod"
value={removalMethod}
values={removalMethodOptions}
isDisabled={!canChangeCategory && !canIgnore}
helpTextWarning={translate(
'RemoveQueueItemRemovalMethodHelpTextWarning'
)}
onChange={handleRemovalMethodChange}
/>
</FormGroup>
)}
<FormGroup>
<FormLabel>
{multipleSelected
? translate('BlocklistReleases')
: translate('BlocklistRelease')}
</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="blocklistMethod"
value={blocklistMethod}
values={blocklistMethodOptions}
helpText={translate('BlocklistReleaseHelpText')}
onChange={handleBlocklistMethodChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter>
<Button onPress={handleModalClose}>{translate('Close')}</Button>
<Button kind={kinds.DANGER} onPress={handleConfirmRemove}>
{translate('Remove')}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
export default RemoveQueueItemModal;

View File

@@ -1,174 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
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 Modal from 'Components/Modal/Modal';
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, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './RemoveQueueItemsModal.css';
class RemoveQueueItemsModal extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
remove: true,
blocklist: false,
skipRedownload: false
};
}
//
// Control
resetState = function() {
this.setState({
remove: true,
blocklist: false,
skipRedownload: false
});
};
//
// Listeners
onRemoveChange = ({ value }) => {
this.setState({ remove: value });
};
onBlocklistChange = ({ value }) => {
this.setState({ blocklist: value });
};
onSkipRedownloadChange = ({ value }) => {
this.setState({ skipRedownload: value });
};
onRemoveConfirmed = () => {
const state = this.state;
this.resetState();
this.props.onRemovePress(state);
};
onModalClose = () => {
this.resetState();
this.props.onModalClose();
};
//
// Render
render() {
const {
isOpen,
selectedCount,
canIgnore,
allPending
} = this.props;
const { remove, blocklist, skipRedownload } = this.state;
return (
<Modal
isOpen={isOpen}
size={sizes.MEDIUM}
onModalClose={this.onModalClose}
>
<ModalContent
onModalClose={this.onModalClose}
>
<ModalHeader>
{selectedCount > 1 ? translate('RemoveSelectedItems') : translate('RemoveSelectedItem')}
</ModalHeader>
<ModalBody>
<div className={styles.message}>
{selectedCount > 1 ? translate('RemoveSelectedItemsQueueMessageText', { selectedCount }) : translate('RemoveSelectedItemQueueMessageText')}
</div>
{
allPending ?
null :
<FormGroup>
<FormLabel>{translate('RemoveFromDownloadClient')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="remove"
value={remove}
helpTextWarning={translate('RemoveFromDownloadClientHelpTextWarning')}
isDisabled={!canIgnore}
onChange={this.onRemoveChange}
/>
</FormGroup>
}
<FormGroup>
<FormLabel>
{selectedCount > 1 ? translate('BlocklistReleases') : translate('BlocklistRelease')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="blocklist"
value={blocklist}
helpText={translate('BlocklistReleaseSearchEpisodeAgainHelpText')}
onChange={this.onBlocklistChange}
/>
</FormGroup>
{
blocklist ?
<FormGroup>
<FormLabel>{translate('SkipRedownload')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="skipRedownload"
value={skipRedownload}
helpText={translate('SkipRedownloadHelpText')}
onChange={this.onSkipRedownloadChange}
/>
</FormGroup> :
null
}
</ModalBody>
<ModalFooter>
<Button onPress={this.onModalClose}>
{translate('Close')}
</Button>
<Button
kind={kinds.DANGER}
onPress={this.onRemoveConfirmed}
>
{translate('Remove')}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
}
RemoveQueueItemsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
selectedCount: PropTypes.number.isRequired,
canIgnore: PropTypes.bool.isRequired,
allPending: PropTypes.bool.isRequired,
onRemovePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default RemoveQueueItemsModal;

View File

@@ -44,7 +44,16 @@ export interface CustomFilter {
filers: PropertyFilter[];
}
export interface AppSectionState {
dimensions: {
isSmallScreen: boolean;
width: number;
height: number;
};
}
interface AppState {
app: AppSectionState;
calendar: CalendarAppState;
commands: CommandAppState;
episodeFiles: EpisodeFilesAppState;

View File

@@ -7,6 +7,7 @@ import AppSectionState, {
import Language from 'Language/Language';
import DownloadClient from 'typings/DownloadClient';
import ImportList from 'typings/ImportList';
import ImportListOptionsSettings from 'typings/ImportListOptionsSettings';
import Indexer from 'typings/Indexer';
import Notification from 'typings/Notification';
import QualityProfile from 'typings/QualityProfile';
@@ -35,10 +36,15 @@ export interface QualityProfilesAppState
extends AppSectionState<QualityProfile>,
AppSectionSchemaState<QualityProfile> {}
export interface ImportListOptionsSettingsAppState
extends AppSectionItemState<ImportListOptionsSettings>,
AppSectionSaveState {}
export type LanguageSettingsAppState = AppSectionState<Language>;
export type UiSettingsAppState = AppSectionItemState<UiSettings>;
interface SettingsAppState {
advancedSettings: boolean;
downloadClients: DownloadClientAppState;
importLists: ImportListAppState;
indexers: IndexerAppState;
@@ -46,6 +52,7 @@ interface SettingsAppState {
notifications: NotificationAppState;
qualityProfiles: QualityProfilesAppState;
ui: UiSettingsAppState;
importListOptions: ImportListOptionsSettingsAppState;
}
export default SettingsAppState;

View File

@@ -52,6 +52,10 @@ $fullColorGradient: rgba(244, 245, 246, 0.2);
.statusContainer {
display: flex;
align-items: center;
&:global(.fullColor) {
filter: var(--calendarFullColorFilter)
}
}
.statusIcon {

View File

@@ -102,7 +102,12 @@ class CalendarEvent extends Component {
{series.title}
</div>
<div className={styles.statusContainer}>
<div
className={classNames(
styles.statusContainer,
fullColorEvents && 'fullColor'
)}
>
{
missingAbsoluteNumber ?
<Icon
@@ -128,6 +133,7 @@ class CalendarEvent extends Component {
<span className={styles.statusIcon}>
<CalendarEventQueueDetails
{...queueItem}
fullColorEvents={fullColorEvents}
/>
</span> :
null
@@ -150,7 +156,7 @@ class CalendarEvent extends Component {
<Icon
className={styles.statusIcon}
name={icons.EPISODE_FILE}
kind={fullColorEvents ? kinds.DEFAULT : kinds.WARNING}
kind={kinds.WARNING}
title={translate('QualityCutoffNotMet')}
/> :
null
@@ -160,9 +166,8 @@ class CalendarEvent extends Component {
episodeNumber === 1 && seasonNumber > 0 ?
<Icon
className={styles.statusIcon}
name={icons.INFO}
name={icons.PREMIERE}
kind={kinds.INFO}
darken={fullColorEvents}
title={seasonNumber === 1 ? translate('SeriesPremiere') : translate('SeasonPremiere')}
/> :
null
@@ -173,8 +178,8 @@ class CalendarEvent extends Component {
finaleType ?
<Icon
className={styles.statusIcon}
name={icons.INFO}
kind={fullColorEvents ? kinds.DEFAULT : kinds.WARNING}
name={finaleType === 'series' ? icons.FINALE_SERIES : icons.FINALE_SEASON}
kind={finaleType === 'series' ? kinds.DANGER : kinds.WARNING}
title={getFinaleTypeName(finaleType)}
/> :
null
@@ -187,7 +192,6 @@ class CalendarEvent extends Component {
className={styles.statusIcon}
name={icons.INFO}
kind={kinds.PINK}
darken={fullColorEvents}
title={translate('Special')}
/> :
null

View File

@@ -43,6 +43,7 @@
.expandContainer,
.collapseContainer {
display: flex;
align-items: center;
justify-content: center;
}
@@ -50,6 +51,15 @@
margin-bottom: 5px;
}
.statusContainer {
display: flex;
align-items: center;
&:global(.fullColor) {
filter: var(--calendarFullColorFilter)
}
}
.statusIcon {
margin-left: 3px;
}

View File

@@ -16,6 +16,7 @@ interface CssExports {
'onAir': string;
'premiere': string;
'seriesTitle': string;
'statusContainer': string;
'statusIcon': string;
'unaired': string;
'unmonitored': string;

View File

@@ -145,45 +145,51 @@ class CalendarEventGroup extends Component {
{series.title}
</div>
{
isMissingAbsoluteNumber &&
<Icon
containerClassName={styles.statusIcon}
name={icons.WARNING}
title={translate('EpisodeMissingAbsoluteNumber')}
/>
}
<div
className={classNames(
styles.statusContainer,
fullColorEvents && 'fullColor'
)}
>
{
isMissingAbsoluteNumber &&
<Icon
containerClassName={styles.statusIcon}
name={icons.WARNING}
title={translate('EpisodeMissingAbsoluteNumber')}
/>
}
{
anyDownloading &&
<Icon
containerClassName={styles.statusIcon}
name={icons.DOWNLOADING}
title={translate('AnEpisodeIsDownloading')}
/>
}
{
anyDownloading &&
<Icon
containerClassName={styles.statusIcon}
name={icons.DOWNLOADING}
title={translate('AnEpisodeIsDownloading')}
/>
}
{
firstEpisode.episodeNumber === 1 && seasonNumber > 0 &&
<Icon
containerClassName={styles.statusIcon}
name={icons.INFO}
kind={kinds.INFO}
darken={fullColorEvents}
title={seasonNumber === 1 ? translate('SeriesPremiere') : translate('SeasonPremiere')}
/>
}
{
firstEpisode.episodeNumber === 1 && seasonNumber > 0 &&
<Icon
containerClassName={styles.statusIcon}
name={icons.PREMIERE}
kind={kinds.INFO}
title={seasonNumber === 1 ? translate('SeriesPremiere') : translate('SeasonPremiere')}
/>
}
{
showFinaleIcon &&
lastEpisode.finaleType ?
<Icon
containerClassName={styles.statusIcon}
name={icons.INFO}
kind={fullColorEvents ? kinds.DEFAULT : kinds.WARNING}
title={getFinaleTypeName(lastEpisode.finaleType)}
/> : null
}
{
showFinaleIcon &&
lastEpisode.finaleType ?
<Icon
containerClassName={styles.statusIcon}
name={lastEpisode.finaleType === 'series' ? icons.FINALE_SERIES : icons.FINALE_SEASON}
kind={lastEpisode.finaleType === 'series' ? kinds.DANGER : kinds.WARNING}
title={getFinaleTypeName(lastEpisode.finaleType)}
/> : null
}
</div>
</div>
<div className={styles.airingInfo}>
@@ -218,16 +224,19 @@ class CalendarEventGroup extends Component {
</div>
{
showEpisodeInformation &&
showEpisodeInformation ?
<Link
className={styles.expandContainer}
component="div"
onPress={this.onExpandPress}
>
&nbsp;
<Icon
name={icons.EXPAND}
/>
</Link>
&nbsp;
</Link> :
null
}
</div>
);

View File

@@ -10,7 +10,7 @@ function createIsDownloadingSelector() {
(state) => state.queue.details,
(episodeIds, details) => {
return details.items.some((item) => {
return item.episode && episodeIds.includes(item.episode.id);
return !!(item.episodeId && episodeIds.includes(item.episodeId));
});
}
);

View File

@@ -22,9 +22,20 @@ function Legend(props) {
if (showFinaleIcon) {
iconsToShow.push(
<LegendIconItem
name="Finale"
icon={icons.INFO}
kind={fullColorEvents ? kinds.DEFAULT : kinds.WARNING}
name={translate('SeasonFinale')}
icon={icons.FINALE_SEASON}
kind={kinds.WARNING}
fullColorEvents={fullColorEvents}
tooltip={translate('CalendarLegendSeriesFinaleTooltip')}
/>
);
iconsToShow.push(
<LegendIconItem
name={translate('SeriesFinale')}
icon={icons.FINALE_SERIES}
kind={kinds.DANGER}
fullColorEvents={fullColorEvents}
tooltip={translate('CalendarLegendSeriesFinaleTooltip')}
/>
);
@@ -33,10 +44,10 @@ function Legend(props) {
if (showSpecialIcon) {
iconsToShow.push(
<LegendIconItem
name="Special"
name={translate('Special')}
icon={icons.INFO}
kind={kinds.PINK}
darken={fullColorEvents}
fullColorEvents={fullColorEvents}
tooltip={translate('SpecialEpisode')}
/>
);
@@ -45,9 +56,10 @@ function Legend(props) {
if (showCutoffUnmetIcon) {
iconsToShow.push(
<LegendIconItem
name="Cutoff Not Met"
name={translate('Cutoff Not Met')}
icon={icons.EPISODE_FILE}
kind={fullColorEvents ? kinds.DEFAULT : kinds.WARNING}
kind={kinds.WARNING}
fullColorEvents={fullColorEvents}
tooltip={translate('QualityCutoffNotMet')}
/>
);
@@ -112,10 +124,10 @@ function Legend(props) {
<div>
<LegendIconItem
name="Premiere"
icon={icons.INFO}
name={translate('Premiere')}
icon={icons.PREMIERE}
kind={kinds.INFO}
darken={true}
fullColorEvents={fullColorEvents}
tooltip={translate('CalendarLegendSeriesPremiereTooltip')}
/>
@@ -129,6 +141,12 @@ function Legend(props) {
{iconsToShow[2]}
</div>
}
{
iconsToShow.length > 3 &&
<div>
{iconsToShow[3]}
</div>
}
</div>
);
}

View File

@@ -7,4 +7,8 @@
.icon {
margin-right: 5px;
&:global(.fullColorEvents) {
filter: var(--calendarFullColorFilter)
}
}

View File

@@ -1,3 +1,4 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
@@ -6,9 +7,9 @@ import styles from './LegendIconItem.css';
function LegendIconItem(props) {
const {
name,
fullColorEvents,
icon,
kind,
darken,
tooltip
} = props;
@@ -18,9 +19,11 @@ function LegendIconItem(props) {
title={tooltip}
>
<Icon
className={styles.icon}
className={classNames(
styles.icon,
fullColorEvents && 'fullColorEvents'
)}
name={icon}
darken={darken}
kind={kind}
/>
@@ -31,14 +34,10 @@ function LegendIconItem(props) {
LegendIconItem.propTypes = {
name: PropTypes.string.isRequired,
fullColorEvents: PropTypes.bool.isRequired,
icon: PropTypes.object.isRequired,
kind: PropTypes.string.isRequired,
darken: PropTypes.bool.isRequired,
tooltip: PropTypes.string.isRequired
};
LegendIconItem.defaultProps = {
darken: false
};
export default LegendIconItem;

View File

@@ -30,22 +30,24 @@ function CustomFiltersModalContent(props) {
<ModalBody>
{
customFilters.map((customFilter) => {
return (
<CustomFilter
key={customFilter.id}
id={customFilter.id}
label={customFilter.label}
filters={customFilter.filters}
selectedFilterKey={selectedFilterKey}
isDeleting={isDeleting}
deleteError={deleteError}
dispatchSetFilter={dispatchSetFilter}
dispatchDeleteCustomFilter={dispatchDeleteCustomFilter}
onEditPress={onEditCustomFilter}
/>
);
})
customFilters
.sort((a, b) => a.label.localeCompare(b.label))
.map((customFilter) => {
return (
<CustomFilter
key={customFilter.id}
id={customFilter.id}
label={customFilter.label}
filters={customFilter.filters}
selectedFilterKey={selectedFilterKey}
isDeleting={isDeleting}
deleteError={deleteError}
dispatchSetFilter={dispatchSetFilter}
dispatchDeleteCustomFilter={dispatchDeleteCustomFilter}
onEditPress={onEditCustomFilter}
/>
);
})
}
<div className={styles.addButtonContainer}>

View File

@@ -264,6 +264,7 @@ FormInputGroup.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.any,
values: PropTypes.arrayOf(PropTypes.any),
isDisabled: PropTypes.bool,
type: PropTypes.string.isRequired,
kind: PropTypes.oneOf(kinds.all),
min: PropTypes.number,

View File

@@ -12,18 +12,10 @@
.info {
color: var(--infoColor);
&:global(.darken) {
color: color(var(--infoColor) shade(30%));
}
}
.pink {
color: var(--pink);
&:global(.darken) {
color: color(var(--pink) shade(30%));
}
}
.success {

View File

@@ -18,7 +18,6 @@ class Icon extends PureComponent {
kind,
size,
title,
darken,
isSpinning,
...otherProps
} = this.props;
@@ -27,8 +26,7 @@ class Icon extends PureComponent {
<FontAwesomeIcon
className={classNames(
className,
styles[kind],
darken && 'darken'
styles[kind]
)}
icon={name}
spin={isSpinning}
@@ -61,7 +59,6 @@ Icon.propTypes = {
kind: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,
title: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
darken: PropTypes.bool.isRequired,
isSpinning: PropTypes.bool.isRequired,
fixedWidth: PropTypes.bool.isRequired
};
@@ -69,7 +66,6 @@ Icon.propTypes = {
Icon.defaultProps = {
kind: kinds.DEFAULT,
size: 14,
darken: false,
isSpinning: false,
fixedWidth: false
};

View File

@@ -40,18 +40,26 @@ class FilterMenuContent extends Component {
}
{
customFilters.map((filter) => {
return (
<FilterMenuItem
key={filter.id}
filterKey={filter.id}
selectedFilterKey={selectedFilterKey}
onPress={onFilterSelect}
>
{filter.label}
</FilterMenuItem>
);
})
customFilters.length > 0 ?
<MenuItemSeparator /> :
null
}
{
customFilters
.sort((a, b) => a.label.localeCompare(b.label))
.map((filter) => {
return (
<FilterMenuItem
key={filter.id}
filterKey={filter.id}
selectedFilterKey={selectedFilterKey}
onPress={onFilterSelect}
>
{filter.label}
</FilterMenuItem>
);
})
}
{

View File

@@ -41,6 +41,9 @@ import {
faChevronCircleUp as fasChevronCircleUp,
faCircle as fasCircle,
faCircleDown as fasCircleDown,
faCirclePause as fasCirclePause,
faCirclePlay as fasCirclePlay,
faCircleStop as fasCircleStop,
faCloud as fasCloud,
faCloudDownloadAlt as fasCloudDownloadAlt,
faCog as fasCog,
@@ -55,6 +58,7 @@ import {
faEye as fasEye,
faFastBackward as fasFastBackward,
faFastForward as fasFastForward,
faFileCircleQuestion as fasFileCircleQuestion,
faFileExport as fasFileExport,
faFileInvoice as farFileInvoice,
faFilter as fasFilter,
@@ -146,7 +150,10 @@ export const EXPORT = fasFileExport;
export const EXTERNAL_LINK = fasExternalLinkAlt;
export const FATAL = fasTimesCircle;
export const FILE = farFile;
export const FILE_MISSING = fasFileCircleQuestion;
export const FILTER = fasFilter;
export const FINALE_SEASON = fasCirclePause;
export const FINALE_SERIES = fasCircleStop;
export const FOOTNOTE = fasAsterisk;
export const FOLDER = farFolder;
export const FOLDER_OPEN = fasFolderOpen;
@@ -178,6 +185,7 @@ export const PARENT = fasLevelUpAlt;
export const PARSE = fasCalculator;
export const PAUSED = fasPause;
export const PENDING = farClock;
export const PREMIERE = fasCirclePlay;
export const PROFILE = fasUser;
export const POSTER = fasTh;
export const QUEUED = fasCloud;

View File

@@ -269,33 +269,6 @@ function InteractiveImportModalContent(
const [interactiveImportErrorMessage, setInteractiveImportErrorMessage] =
useState<string | null>(null);
const [selectState, setSelectState] = useSelectState();
const [bulkSelectOptions, setBulkSelectOptions] = useState([
{
key: 'select',
value: translate('SelectDropdown'),
disabled: true,
},
{
key: 'season',
value: translate('SelectSeason'),
},
{
key: 'episode',
value: translate('SelectEpisodes'),
},
{
key: 'quality',
value: translate('SelectQuality'),
},
{
key: 'releaseGroup',
value: translate('SelectReleaseGroup'),
},
{
key: 'language',
value: translate('SelectLanguage'),
},
]);
const { allSelected, allUnselected, selectedState } = selectState;
const previousIsDeleting = usePrevious(isDeleting);
const dispatch = useDispatch();
@@ -318,19 +291,72 @@ function InteractiveImportModalContent(
return getSelectedIds(selectedState);
}, [selectedState]);
const bulkSelectOptions = useMemo(() => {
const { seasonSelectDisabled, episodeSelectDisabled } = items.reduce(
(acc, item) => {
if (!selectedIds.includes(item.id)) {
return acc;
}
const lastSelectedSeason = acc.lastSelectedSeason;
acc.seasonSelectDisabled ||= !item.series;
acc.episodeSelectDisabled ||=
item.seasonNumber === undefined ||
(lastSelectedSeason >= 0 && item.seasonNumber !== lastSelectedSeason);
acc.lastSelectedSeason = item.seasonNumber ?? -1;
return acc;
},
{
seasonSelectDisabled: false,
episodeSelectDisabled: false,
lastSelectedSeason: -1,
}
);
const options = [
{
key: 'select',
value: translate('SelectDropdown'),
disabled: true,
},
{
key: 'season',
value: translate('SelectSeason'),
disabled: seasonSelectDisabled,
},
{
key: 'episode',
value: translate('SelectEpisodes'),
disabled: episodeSelectDisabled,
},
{
key: 'quality',
value: translate('SelectQuality'),
},
{
key: 'releaseGroup',
value: translate('SelectReleaseGroup'),
},
{
key: 'language',
value: translate('SelectLanguage'),
},
];
if (allowSeriesChange) {
options.splice(1, 0, {
key: 'series',
value: translate('SelectSeries'),
});
}
return options;
}, [allowSeriesChange, items, selectedIds]);
useEffect(
() => {
if (allowSeriesChange) {
const newBulkSelectOptions = [...bulkSelectOptions];
newBulkSelectOptions.splice(1, 0, {
key: 'series',
value: translate('SelectSeries'),
});
setBulkSelectOptions(newBulkSelectOptions);
}
if (initialSortKey) {
const sortProps: { sortKey: string; sortDirection?: string } = {
sortKey: initialSortKey,

View File

@@ -129,10 +129,8 @@ class SeriesDetailsSeason extends Component {
items
} = this.props;
const expand = _.some(items, (item) => {
return isAfter(item.airDateUtc) ||
isAfter(item.airDateUtc, { days: -30 });
});
const expand = _.some(items, (item) => isAfter(item.airDateUtc) || isAfter(item.airDateUtc, { days: -30 })) ||
items.every((item) => !item.airDateUtc);
onExpandPress(seasonNumber, expand && seasonNumber > 0);
}

View File

@@ -190,11 +190,15 @@ export default function SeriesIndexPosters(props: SeriesIndexPostersProps) {
if (isSmallScreen) {
const padding = bodyPaddingSmallScreen - 5;
const width = window.innerWidth - padding * 2;
const height = window.innerHeight;
setSize({
width: window.innerWidth - padding * 2,
height: window.innerHeight,
});
if (width !== size.width || height !== size.height) {
setSize({
width,
height,
});
}
return;
}
@@ -202,13 +206,18 @@ export default function SeriesIndexPosters(props: SeriesIndexPostersProps) {
if (current) {
const width = current.clientWidth;
const padding = bodyPadding - 5;
const finalWidth = width - padding * 2;
if (Math.abs(size.width - finalWidth) < 20 || size.width === finalWidth) {
return;
}
setSize({
width: width - padding * 2,
width: finalWidth,
height: window.innerHeight,
});
}
}, [isSmallScreen, scrollerRef, bounds]);
}, [isSmallScreen, size, scrollerRef, bounds]);
useEffect(() => {
const currentScrollerRef = scrollerRef.current as HTMLElement;

View File

@@ -49,7 +49,7 @@ function EditSpecificationModalContent(props) {
{...otherProps}
>
{
fields && fields.some((x) => x.label === translate('RegularExpression')) &&
fields && fields.some((x) => x.label === translate('CustomFormatsSpecificationRegularExpression')) &&
<Alert kind={kinds.INFO}>
<div>
<InlineMarkdown data={translate('ConditionUsingRegularExpressions')} />

View File

@@ -133,7 +133,7 @@ class EditDownloadClientModalContent extends Component {
<FormInputGroup
type={inputTypes.NUMBER}
name="priority"
helpText={translate('PriorityHelpText')}
helpText={translate('DownloadClientPriorityHelpText')}
min={1}
max={50}
{...priority}

View File

@@ -10,6 +10,7 @@ import translate from 'Utilities/String/translate';
import ImportListsExclusionsConnector from './ImportListExclusions/ImportListExclusionsConnector';
import ImportListsConnector from './ImportLists/ImportListsConnector';
import ManageImportListsModal from './ImportLists/Manage/ManageImportListsModal';
import ImportListOptions from './Options/ImportListOptions';
class ImportListSettings extends Component {
@@ -19,7 +20,10 @@ class ImportListSettings extends Component {
constructor(props, context) {
super(props, context);
this._saveCallback = null;
this.state = {
isSaving: false,
hasPendingChanges: false,
isManageImportListsOpen: false
};
@@ -28,6 +32,14 @@ class ImportListSettings extends Component {
//
// Listeners
setChildSave = (saveCallback) => {
this._saveCallback = saveCallback;
};
onChildStateChange = (payload) => {
this.setState(payload);
};
setListOptionsRef = (ref) => {
this._listOptions = ref;
};
@@ -47,7 +59,9 @@ class ImportListSettings extends Component {
};
onSavePress = () => {
this._listOptions.getWrappedInstance().save();
if (this._saveCallback) {
this._saveCallback();
}
};
//
@@ -93,6 +107,12 @@ class ImportListSettings extends Component {
<PageContentBody>
<ImportListsConnector />
<ImportListOptions
setChildSave={this.setChildSave}
onChildStateChange={this.onChildStateChange}
/>
<ImportListsExclusionsConnector />
<ManageImportListsModal
isOpen={isManageImportListsOpen}

View File

@@ -0,0 +1,148 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FieldSet from 'Components/FieldSet';
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 LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { inputTypes } from 'Helpers/Props';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import {
fetchImportListOptions,
saveImportListOptions,
setImportListOptionsValue,
} from 'Store/Actions/settingsActions';
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
import translate from 'Utilities/String/translate';
const SECTION = 'importListOptions';
const cleanLibraryLevelOptions = [
{ key: 'disabled', value: () => translate('Disabled') },
{ key: 'logOnly', value: () => translate('LogOnly') },
{ key: 'keepAndUnmonitor', value: () => translate('KeepAndUnmonitorSeries') },
{ key: 'keepAndTag', value: () => translate('KeepAndTagSeries') },
];
function createImportListOptionsSelector() {
return createSelector(
(state: AppState) => state.settings.advancedSettings,
createSettingsSectionSelector(SECTION),
(advancedSettings, sectionSettings) => {
return {
advancedSettings,
save: sectionSettings.isSaving,
...sectionSettings,
};
}
);
}
interface ImportListOptionsPageProps {
setChildSave(saveCallback: () => void): void;
onChildStateChange(payload: unknown): void;
}
function ImportListOptions(props: ImportListOptionsPageProps) {
const { setChildSave, onChildStateChange } = props;
const selected = useSelector(createImportListOptionsSelector());
const {
isSaving,
hasPendingChanges,
advancedSettings,
isFetching,
error,
settings,
hasSettings,
} = selected;
const { listSyncLevel, listSyncTag } = settings;
const dispatch = useDispatch();
const onInputChange = useCallback(
({ name, value }: { name: string; value: unknown }) => {
// @ts-expect-error 'setImportListOptionsValue' isn't typed yet
dispatch(setImportListOptionsValue({ name, value }));
},
[dispatch]
);
const onTagChange = useCallback(
({ name, value }: { name: string; value: number[] }) => {
const id = value.length === 0 ? 0 : value.pop();
// @ts-expect-error 'setImportListOptionsValue' isn't typed yet
dispatch(setImportListOptionsValue({ name, value: id }));
},
[dispatch]
);
useEffect(() => {
dispatch(fetchImportListOptions());
setChildSave(() => dispatch(saveImportListOptions()));
return () => {
dispatch(clearPendingChanges({ section: SECTION }));
};
}, [dispatch, setChildSave]);
useEffect(() => {
onChildStateChange({
isSaving,
hasPendingChanges,
});
}, [onChildStateChange, isSaving, hasPendingChanges]);
const translatedLevelOptions = cleanLibraryLevelOptions.map(
({ key, value }) => {
return {
key,
value: value(),
};
}
);
return advancedSettings ? (
<FieldSet legend={translate('Options')}>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<div>{translate('UnableToLoadListOptions')}</div>
) : null}
{hasSettings && !isFetching && !error ? (
<Form>
<FormGroup advancedSettings={advancedSettings} isAdvanced={true}>
<FormLabel>{translate('CleanLibraryLevel')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="listSyncLevel"
values={translatedLevelOptions}
helpText={translate('ListSyncLevelHelpText')}
onChange={onInputChange}
{...listSyncLevel}
/>
</FormGroup>
{listSyncLevel.value === 'keepAndTag' ? (
<FormGroup advancedSettings={advancedSettings} isAdvanced={true}>
<FormLabel>{translate('ListSyncTag')}</FormLabel>
<FormInputGroup
{...listSyncTag}
type={inputTypes.TAG}
name="listSyncTag"
value={listSyncTag.value === 0 ? [] : [listSyncTag.value]}
helpText={translate('ListSyncTagHelpText')}
onChange={onTagChange}
/>
</FormGroup>
) : null}
</Form>
) : null}
</FieldSet>
) : null;
}
export default ImportListOptions;

View File

@@ -191,7 +191,7 @@ class Notification extends Component {
}
{
!onGrab && !onDownload && !onRename && !onHealthIssue && !onHealthRestored && !onApplicationUpdate && !onSeriesDelete && !onEpisodeFileDelete && !onManualInteractionRequired ?
!onGrab && !onDownload && !onRename && !onHealthIssue && !onHealthRestored && !onApplicationUpdate && !onSeriesAdd && !onSeriesDelete && !onEpisodeFileDelete && !onManualInteractionRequired ?
<Label
kind={kinds.DISABLED}
outline={true}

View File

@@ -15,12 +15,17 @@ function PendingChangesModal(props) {
isOpen,
onConfirm,
onCancel,
bindShortcut
bindShortcut,
unbindShortcut
} = props;
useEffect(() => {
bindShortcut('enter', onConfirm);
}, [bindShortcut, onConfirm]);
if (isOpen) {
bindShortcut('enter', onConfirm);
return () => unbindShortcut('enter', onConfirm);
}
}, [bindShortcut, unbindShortcut, isOpen, onConfirm]);
return (
<Modal
@@ -61,7 +66,8 @@ PendingChangesModal.propTypes = {
kind: PropTypes.oneOf(kinds.all),
onConfirm: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
bindShortcut: PropTypes.func.isRequired
bindShortcut: PropTypes.func.isRequired,
unbindShortcut: PropTypes.func.isRequired
};
PendingChangesModal.defaultProps = {

View File

@@ -88,7 +88,7 @@ function EditDelayProfileModalContent(props) {
{
!isFetching && !!error ?
<div>
{translate('AddQualityProfileError')}
{translate('AddDelayProfileError')}
</div> :
null
}

View File

@@ -94,7 +94,12 @@ export default {
items: [],
pendingChanges: {},
sortKey: 'name',
sortDirection: sortDirections.DESCENDING
sortDirection: sortDirections.ASCENDING,
sortPredicates: {
name: function(item) {
return item.name.toLowerCase();
}
}
},
//

View File

@@ -0,0 +1,64 @@
import { createAction } from 'redux-actions';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createSaveHandler from 'Store/Actions/Creators/createSaveHandler';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks';
//
// Variables
const section = 'settings.importListOptions';
//
// Actions Types
export const FETCH_IMPORT_LIST_OPTIONS = 'settings/importListOptions/fetchImportListOptions';
export const SAVE_IMPORT_LIST_OPTIONS = 'settings/importListOptions/saveImportListOptions';
export const SET_IMPORT_LIST_OPTIONS_VALUE = 'settings/importListOptions/setImportListOptionsValue';
//
// Action Creators
export const fetchImportListOptions = createThunk(FETCH_IMPORT_LIST_OPTIONS);
export const saveImportListOptions = createThunk(SAVE_IMPORT_LIST_OPTIONS);
export const setImportListOptionsValue = createAction(SET_IMPORT_LIST_OPTIONS_VALUE, (payload) => {
return {
section,
...payload
};
});
//
// Details
export default {
//
// State
defaultState: {
isFetching: false,
isPopulated: false,
error: null,
pendingChanges: {},
isSaving: false,
saveError: null,
item: {}
},
//
// Action Handlers
actionHandlers: {
[FETCH_IMPORT_LIST_OPTIONS]: createFetchHandler(section, '/config/importlist'),
[SAVE_IMPORT_LIST_OPTIONS]: createSaveHandler(section, '/config/importlist')
},
//
// Reducers
reducers: {
[SET_IMPORT_LIST_OPTIONS_VALUE]: createSetSettingValueReducer(section)
}
};

View File

@@ -99,7 +99,12 @@ export default {
items: [],
pendingChanges: {},
sortKey: 'name',
sortDirection: sortDirections.DESCENDING
sortDirection: sortDirections.ASCENDING,
sortPredicates: {
name: function(item) {
return item.name.toLowerCase();
}
}
},
//
@@ -149,7 +154,13 @@ export default {
delete selectedSchema.name;
selectedSchema.fields = selectedSchema.fields.map((field) => {
return { ...field };
const newField = { ...field };
if (newField.privacy === 'apiKey' || newField.privacy === 'password') {
newField.value = '';
}
return newField;
});
newState.selectedSchema = selectedSchema;

View File

@@ -158,6 +158,12 @@ export const defaultState = {
isSortable: true,
isVisible: true
},
{
name: 'added',
label: () => translate('Added'),
isSortable: true,
isVisible: false
},
{
name: 'progress',
label: () => translate('Progress'),
@@ -424,13 +430,14 @@ export const actionHandlers = handleThunks({
id,
remove,
blocklist,
skipRedownload
skipRedownload,
changeCategory
} = payload;
dispatch(updateItem({ section: paged, id, isRemoving: true }));
const promise = createAjaxRequest({
url: `/queue/${id}?removeFromClient=${remove}&blocklist=${blocklist}&skipRedownload=${skipRedownload}`,
url: `/queue/${id}?removeFromClient=${remove}&blocklist=${blocklist}&skipRedownload=${skipRedownload}&changeCategory=${changeCategory}`,
method: 'DELETE'
}).request;
@@ -448,7 +455,8 @@ export const actionHandlers = handleThunks({
ids,
remove,
blocklist,
skipRedownload
skipRedownload,
changeCategory
} = payload;
dispatch(batchActions([
@@ -464,7 +472,7 @@ export const actionHandlers = handleThunks({
]));
const promise = createAjaxRequest({
url: `/queue/bulk?removeFromClient=${remove}&blocklist=${blocklist}&skipRedownload=${skipRedownload}`,
url: `/queue/bulk?removeFromClient=${remove}&blocklist=${blocklist}&skipRedownload=${skipRedownload}&changeCategory=${changeCategory}`,
method: 'DELETE',
dataType: 'json',
contentType: 'application/json',

View File

@@ -10,6 +10,7 @@ import downloadClientOptions from './Settings/downloadClientOptions';
import downloadClients from './Settings/downloadClients';
import general from './Settings/general';
import importListExclusions from './Settings/importListExclusions';
import importListOptions from './Settings/importListOptions';
import importLists from './Settings/importLists';
import indexerOptions from './Settings/indexerOptions';
import indexers from './Settings/indexers';
@@ -33,6 +34,7 @@ export * from './Settings/delayProfiles';
export * from './Settings/downloadClients';
export * from './Settings/downloadClientOptions';
export * from './Settings/general';
export * from './Settings/importListOptions';
export * from './Settings/importLists';
export * from './Settings/importListExclusions';
export * from './Settings/indexerOptions';
@@ -69,6 +71,7 @@ export const defaultState = {
general: general.defaultState,
importLists: importLists.defaultState,
importListExclusions: importListExclusions.defaultState,
importListOptions: importListOptions.defaultState,
indexerOptions: indexerOptions.defaultState,
indexers: indexers.defaultState,
languages: languages.defaultState,
@@ -112,6 +115,7 @@ export const actionHandlers = handleThunks({
...general.actionHandlers,
...importLists.actionHandlers,
...importListExclusions.actionHandlers,
...importListOptions.actionHandlers,
...indexerOptions.actionHandlers,
...indexers.actionHandlers,
...languages.actionHandlers,
@@ -146,6 +150,7 @@ export const reducers = createHandleActions({
...general.reducers,
...importLists.reducers,
...importListExclusions.reducers,
...importListOptions.reducers,
...indexerOptions.reducers,
...indexers.reducers,
...languages.reducers,

View File

@@ -1,8 +1,9 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
function createDimensionsSelector() {
return createSelector(
(state) => state.app.dimensions,
(state: AppState) => state.app.dimensions,
(dimensions) => {
return dimensions;
}

View File

@@ -1,32 +0,0 @@
import { createSelector } from 'reselect';
import selectSettings from 'Store/Selectors/selectSettings';
function createSettingsSectionSelector(section) {
return createSelector(
(state) => state.settings[section],
(sectionSettings) => {
const {
isFetching,
isPopulated,
error,
item,
pendingChanges,
isSaving,
saveError
} = sectionSettings;
const settings = selectSettings(item, pendingChanges, saveError);
return {
isFetching,
isPopulated,
error,
isSaving,
saveError,
...settings
};
}
);
}
export default createSettingsSectionSelector;

View File

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

View File

@@ -213,6 +213,8 @@ module.exports = {
calendarTextDim: '#eee',
calendarTextDimAlternate: '#fff',
calendarFullColorFilter: 'grayscale(90%) contrast(200%) saturate(50%)',
//
// Table

View File

@@ -215,6 +215,8 @@ module.exports = {
calendarTextDim: '#666',
calendarTextDimAlternate: '#242424',
calendarFullColorFilter: 'brightness(30%)',
//
// Table

View File

@@ -24,6 +24,7 @@ class About extends Component {
runtimeVersion,
databaseVersion,
databaseType,
migrationVersion,
appData,
startupPath,
mode,
@@ -76,6 +77,11 @@ class About extends Component {
data={`${titleCase(databaseType)} ${databaseVersion}`}
/>
<DescriptionListItem
title={translate('DatabaseMigration')}
data={migrationVersion}
/>
<DescriptionListItem
title={translate('AppDataDirectory')}
data={appData}
@@ -117,6 +123,7 @@ About.propTypes = {
isDocker: PropTypes.bool.isRequired,
databaseType: PropTypes.string.isRequired,
databaseVersion: PropTypes.string.isRequired,
migrationVersion: PropTypes.number.isRequired,
appData: PropTypes.string.isRequired,
startupPath: PropTypes.string.isRequired,
mode: PropTypes.string.isRequired,

View File

@@ -0,0 +1,10 @@
export type ListSyncLevel =
| 'disabled'
| 'logOnly'
| 'keepAndUnmonitor'
| 'keepAndTag';
export default interface ImportListOptionsSettings {
listSyncLevel: ListSyncLevel;
listSyncTag: number;
}

View File

@@ -28,6 +28,7 @@ interface Queue extends ModelBase {
sizeleft: number;
timeleft: string;
estimatedCompletionTime: string;
added?: string;
status: string;
trackedDownloadStatus: QueueTrackedDownloadStatus;
trackedDownloadState: QueueTrackedDownloadState;

View File

@@ -1,4 +1,5 @@
export interface UiSettings {
theme: string;
showRelativeDates: boolean;
shortDateFormat: string;
longDateFormat: string;

View File

@@ -0,0 +1,9 @@
export interface Pending<T> {
value: T;
errors: any[];
warnings: any[];
}
export type PendingSection<T> = {
[K in keyof T]: Pending<T[K]>;
};

View File

@@ -10,6 +10,16 @@ namespace NzbDrone.Common.Test.DiskTests
public abstract class DiskProviderFixtureBase<TSubject> : TestBase<TSubject>
where TSubject : class, IDiskProvider
{
[Test]
public void writealltext_should_truncate_existing()
{
var file = GetTempFilePath();
Subject.WriteAllText(file, "A pretty long string");
Subject.WriteAllText(file, "A short string");
Subject.ReadAllText(file).Should().Be("A short string");
}
[Test]
[Retry(5)]
public void directory_exist_should_be_able_to_find_existing_folder()

View File

@@ -0,0 +1,17 @@
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Common.Extensions;
namespace NzbDrone.Common.Test.ExtensionTests.StringExtensionTests
{
[TestFixture]
public class ReverseFixture
{
[TestCase("input", "tupni")]
[TestCase("racecar", "racecar")]
public void should_reverse_string(string input, string expected)
{
input.Reverse().Should().Be(expected);
}
}
}

View File

@@ -131,6 +131,16 @@ namespace NzbDrone.Common.Test.Http
response.Content.Should().NotBeNullOrWhiteSpace();
}
[Test]
public void should_throw_timeout_request()
{
var request = new HttpRequest($"https://{_httpBinHost}/delay/10");
request.RequestTimeout = new TimeSpan(0, 0, 5);
Assert.ThrowsAsync<WebException>(async () => await Subject.ExecuteAsync(request));
}
[Test]
public async Task should_execute_https_get()
{

View File

@@ -131,7 +131,7 @@ namespace NzbDrone.Common.Disk
{
var testPath = Path.Combine(path, "sonarr_write_test.txt");
var testContent = $"This file was created to verify if '{path}' is writable. It should've been automatically deleted. Feel free to delete it.";
File.WriteAllText(testPath, testContent);
WriteAllText(testPath, testContent);
File.Delete(testPath);
return true;
}
@@ -311,7 +311,16 @@ namespace NzbDrone.Common.Disk
{
Ensure.That(filename, () => filename).IsValidPath(PathValidationType.CurrentOs);
RemoveReadOnly(filename);
File.WriteAllText(filename, contents);
// File.WriteAllText is broken on net core when writing to some CIFS mounts
// This workaround from https://github.com/dotnet/runtime/issues/42790#issuecomment-700362617
using (var fs = new FileStream(filename, FileMode.Create, FileAccess.Write, FileShare.None))
{
using (var writer = new StreamWriter(fs))
{
writer.Write(contents);
}
}
}
public void FolderSetLastWriteTime(string path, DateTime dateTime)

View File

@@ -242,5 +242,14 @@ namespace NzbDrone.Common.Extensions
{
return input.Contains(':') ? $"[{input}]" : input;
}
public static string Reverse(this string text)
{
var array = text.ToCharArray();
Array.Reverse(array);
return new string(array);
}
}
}

View File

@@ -102,31 +102,38 @@ namespace NzbDrone.Common.Http.Dispatchers
var httpClient = GetClient(request.Url);
using var responseMessage = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cts.Token);
try
{
byte[] data = null;
try
using var responseMessage = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cts.Token);
{
if (request.ResponseStream != null && responseMessage.StatusCode == HttpStatusCode.OK)
byte[] data = null;
try
{
await responseMessage.Content.CopyToAsync(request.ResponseStream, null, cts.Token);
if (request.ResponseStream != null && responseMessage.StatusCode == HttpStatusCode.OK)
{
await responseMessage.Content.CopyToAsync(request.ResponseStream, null, cts.Token);
}
else
{
data = await responseMessage.Content.ReadAsByteArrayAsync(cts.Token);
}
}
else
catch (Exception ex)
{
data = await responseMessage.Content.ReadAsByteArrayAsync(cts.Token);
throw new WebException("Failed to read complete http response", ex, WebExceptionStatus.ReceiveFailure, null);
}
var headers = responseMessage.Headers.ToNameValueCollection();
headers.Add(responseMessage.Content.Headers.ToNameValueCollection());
return new HttpResponse(request, new HttpHeader(headers), data, responseMessage.StatusCode, responseMessage.Version);
}
catch (Exception ex)
{
throw new WebException("Failed to read complete http response", ex, WebExceptionStatus.ReceiveFailure, null);
}
var headers = responseMessage.Headers.ToNameValueCollection();
headers.Add(responseMessage.Content.Headers.ToNameValueCollection());
return new HttpResponse(request, new HttpHeader(headers), data, responseMessage.StatusCode, responseMessage.Version);
}
catch (OperationCanceledException ex) when (cts.IsCancellationRequested)
{
throw new WebException("Http request timed out", ex.InnerException, WebExceptionStatus.Timeout, null);
}
}

View File

@@ -92,6 +92,10 @@ namespace NzbDrone.Common.Http
{
data = new XElement("base64", Convert.ToBase64String(bytes));
}
else if (value is Dictionary<string, string> d)
{
data = new XElement("struct", d.Select(p => new XElement("member", new XElement("name", p.Key), new XElement("value", p.Value))));
}
else
{
throw new InvalidOperationException($"Unhandled argument type {value.GetType().Name}");

View File

@@ -0,0 +1,71 @@
using System.Collections.Generic;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Tv;
namespace NzbDrone.Core.Test.CustomFormats.Specifications.LanguageSpecification
{
[TestFixture]
public class MultiLanguageFixture : CoreTest<Core.CustomFormats.LanguageSpecification>
{
private CustomFormatInput _input;
[SetUp]
public void Setup()
{
_input = new CustomFormatInput
{
EpisodeInfo = Builder<ParsedEpisodeInfo>.CreateNew().Build(),
Series = Builder<Series>.CreateNew().With(s => s.OriginalLanguage = Language.English).Build(),
Size = 100.Megabytes(),
Languages = new List<Language>
{
Language.English,
Language.French
},
Filename = "Series.Title.S01E01"
};
}
[Test]
public void should_match_one_language()
{
Subject.Value = Language.French.Id;
Subject.Negate = false;
Subject.IsSatisfiedBy(_input).Should().BeTrue();
}
[Test]
public void should_not_match_different_language()
{
Subject.Value = Language.Spanish.Id;
Subject.Negate = false;
Subject.IsSatisfiedBy(_input).Should().BeFalse();
}
[Test]
public void should_not_match_negated_when_one_language_matches()
{
Subject.Value = Language.French.Id;
Subject.Negate = true;
Subject.IsSatisfiedBy(_input).Should().BeFalse();
}
[Test]
public void should_not_match_negated_when_all_languages_do_not_match()
{
Subject.Value = Language.Spanish.Id;
Subject.Negate = true;
Subject.IsSatisfiedBy(_input).Should().BeTrue();
}
}
}

View File

@@ -0,0 +1,80 @@
using System.Collections.Generic;
using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Tv;
namespace NzbDrone.Core.Test.CustomFormats.Specifications.LanguageSpecification
{
[TestFixture]
public class OriginalLanguageFixture : CoreTest<Core.CustomFormats.LanguageSpecification>
{
private CustomFormatInput _input;
[SetUp]
public void Setup()
{
_input = new CustomFormatInput
{
EpisodeInfo = Builder<ParsedEpisodeInfo>.CreateNew().Build(),
Series = Builder<Series>.CreateNew().With(s => s.OriginalLanguage = Language.English).Build(),
Size = 100.Megabytes(),
Languages = new List<Language>
{
Language.French
},
Filename = "Series.Title.S01E01"
};
}
public void GivenLanguages(params Language[] languages)
{
_input.Languages = languages.ToList();
}
[Test]
public void should_match_same_single_language()
{
GivenLanguages(Language.English);
Subject.Value = Language.Original.Id;
Subject.Negate = false;
Subject.IsSatisfiedBy(_input).Should().BeTrue();
}
[Test]
public void should_not_match_different_single_language()
{
Subject.Value = Language.Original.Id;
Subject.Negate = false;
Subject.IsSatisfiedBy(_input).Should().BeFalse();
}
[Test]
public void should_not_match_negated_same_single_language()
{
GivenLanguages(Language.English);
Subject.Value = Language.Original.Id;
Subject.Negate = true;
Subject.IsSatisfiedBy(_input).Should().BeFalse();
}
[Test]
public void should_match_negated_different_single_language()
{
Subject.Value = Language.Original.Id;
Subject.Negate = true;
Subject.IsSatisfiedBy(_input).Should().BeTrue();
}
}
}

View File

@@ -0,0 +1,70 @@
using System.Collections.Generic;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Tv;
namespace NzbDrone.Core.Test.CustomFormats.Specifications.LanguageSpecification
{
[TestFixture]
public class SingleLanguageFixture : CoreTest<Core.CustomFormats.LanguageSpecification>
{
private CustomFormatInput _input;
[SetUp]
public void Setup()
{
_input = new CustomFormatInput
{
EpisodeInfo = Builder<ParsedEpisodeInfo>.CreateNew().Build(),
Series = Builder<Series>.CreateNew().With(s => s.OriginalLanguage = Language.English).Build(),
Size = 100.Megabytes(),
Languages = new List<Language>
{
Language.French
},
Filename = "Series.Title.S01E01"
};
}
[Test]
public void should_match_same_language()
{
Subject.Value = Language.French.Id;
Subject.Negate = false;
Subject.IsSatisfiedBy(_input).Should().BeTrue();
}
[Test]
public void should_not_match_different_language()
{
Subject.Value = Language.Spanish.Id;
Subject.Negate = false;
Subject.IsSatisfiedBy(_input).Should().BeFalse();
}
[Test]
public void should_not_match_negated_same_language()
{
Subject.Value = Language.French.Id;
Subject.Negate = true;
Subject.IsSatisfiedBy(_input).Should().BeFalse();
}
[Test]
public void should_match_negated_different_language()
{
Subject.Value = Language.Spanish.Id;
Subject.Negate = true;
Subject.IsSatisfiedBy(_input).Should().BeTrue();
}
}
}

View File

@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dapper;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Datastore.Migration;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.Datastore.Migration
{
[TestFixture]
public class parse_title_from_existing_subtitle_filesFixture : MigrationTest<parse_title_from_existing_subtitle_files>
{
[TestCase("Name (2020) - S01E20 - [AAC 2.0].testtitle.default.eng.forced.ass", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "testtitle", 0)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].eng.default.testtitle.forced.ass", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "testtitle", 0)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].default.eng.testtitle.forced.ass", "Name (2020)/Season 1/Name (2020).mkv", "testtitle", 0)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].testtitle.forced.eng.ass", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "testtitle", 0)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].eng.forced.testtitle.ass", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "testtitle", 0)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].forced.eng.testtitle.ass", "Name (2020)/Season 1/Name (2020).mkv", "testtitle", 0)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].testtitle.default.fra.forced.ass", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "testtitle", 0)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].fra.default.testtitle.forced.ass", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "testtitle", 0)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].default.fra.testtitle.forced.ass", "Name (2020)/Season 1/Name (2020).mkv", "testtitle", 0)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].testtitle.forced.fra.ass", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "testtitle", 0)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].fra.forced.testtitle.ass", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "testtitle", 0)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].forced.fra.testtitle.ass", "Name (2020)/Season 1/Name (2020).mkv", "testtitle", 0)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].default.forced.ass", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", null, 0)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].default.ass", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", null, 0)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].ass", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", null, 0)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].testtitle.ass", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", null, 0)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].testtitle - 3.default.eng.forced.ass", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "testtitle", 3)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].testtitle - 3.forced.eng.ass", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "testtitle", 3)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].eng.forced.testtitle - 3.ass", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "testtitle", 3)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].fra.default.testtitle - 3.forced.ass", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", "testtitle", 3)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].3.default.eng.forced.ass", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", null, 3)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].3.forced.eng.ass", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", null, 3)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].eng.forced.3.ass", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", null, 3)]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].fra.default.3.forced.ass", "Name (2020)/Season 1/Name (2020) - S01E20 - [AAC 2.0].mkv", null, 3)]
[TestCase("Name (2020) - Name.2020.S01E03.REAL.PROPER.1080p.HEVC.x265-MeGusta - 0609901d2ea34acd81c9030980406065.en.forced.srt", "Name (2020)/Season 1/Name (2020) - Name.2020.S01E03.REAL.PROPER.1080p.HEVC.x265-MeGusta - 0609901d2ea34acd81c9030980406065.mkv", null, 0)]
public void should_process_file_with_missing_title(string subtitlePath, string episodePath, string title, int copy)
{
var now = DateTime.UtcNow;
var db = WithDapperMigrationTestDb(c =>
{
c.Insert.IntoTable("SubtitleFiles").Row(new
{
SeriesId = 1,
SeasonNumber = 1,
EpisodeFileId = 1,
RelativePath = subtitlePath,
Added = now,
LastUpdated = now,
Extension = Path.GetExtension(subtitlePath),
Language = 10,
LanguageTags = new List<string> { "sdh" }.ToJson()
});
c.Insert.IntoTable("EpisodeFiles").Row(new
{
Id = 1,
SeriesId = 1,
RelativePath = episodePath,
Quality = new { }.ToJson(),
Size = 0,
DateAdded = now,
SeasonNumber = 1,
Languages = new List<int> { 1 }.ToJson()
});
});
var files = db.Query<SubtitleFile198>("SELECT * FROM \"SubtitleFiles\"").ToList();
files.Should().HaveCount(1);
files.First().Title.Should().Be(title);
files.First().Copy.Should().Be(copy);
files.First().LanguageTags.Should().NotContain("sdh");
files.First().Language.Should().NotBe(10);
}
}
public class SubtitleFile198
{
public int Id { get; set; }
public int SeriesId { get; set; }
public int? EpisodeFileId { get; set; }
public int? SeasonNumber { get; set; }
public string RelativePath { get; set; }
public DateTime Added { get; set; }
public DateTime LastUpdated { get; set; }
public string Extension { get; set; }
public int Language { get; set; }
public int Copy { get; set; }
public string Title { get; set; }
public List<string> LanguageTags { get; set; }
}
}

View File

@@ -0,0 +1,171 @@
using System.Collections.Generic;
using System.Linq;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Datastore.Migration;
using NzbDrone.Core.Notifications.Email;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.Datastore.Migration
{
[TestFixture]
public class email_encryptionFixture : MigrationTest<email_encryption>
{
[Test]
public void should_convert_do_not_require_encryption_to_auto()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("Notifications").Row(new
{
OnGrab = true,
OnDownload = true,
OnUpgrade = true,
OnHealthIssue = true,
IncludeHealthWarnings = true,
OnRename = true,
Name = "Mail Sonarr",
Implementation = "Email",
Tags = "[]",
Settings = new EmailSettings200
{
Server = "smtp.gmail.com",
Port = 563,
To = new List<string> { "dont@email.me" },
RequireEncryption = false
}.ToJson(),
ConfigContract = "EmailSettings"
});
});
var items = db.Query<NotificationDefinition201>("SELECT * FROM \"Notifications\"");
items.Should().HaveCount(1);
items.First().Implementation.Should().Be("Email");
items.First().ConfigContract.Should().Be("EmailSettings");
items.First().Settings.UseEncryption.Should().Be((int)EmailEncryptionType.Preferred);
}
[Test]
public void should_convert_require_encryption_to_always()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("Notifications").Row(new
{
OnGrab = true,
OnDownload = true,
OnUpgrade = true,
OnHealthIssue = true,
IncludeHealthWarnings = true,
OnRename = true,
Name = "Mail Sonarr",
Implementation = "Email",
Tags = "[]",
Settings = new EmailSettings200
{
Server = "smtp.gmail.com",
Port = 563,
To = new List<string> { "dont@email.me" },
RequireEncryption = true
}.ToJson(),
ConfigContract = "EmailSettings"
});
});
var items = db.Query<NotificationDefinition201>("SELECT * FROM \"Notifications\"");
items.Should().HaveCount(1);
items.First().Implementation.Should().Be("Email");
items.First().ConfigContract.Should().Be("EmailSettings");
items.First().Settings.UseEncryption.Should().Be((int)EmailEncryptionType.Always);
}
[Test]
public void should_use_defaults_when_settings_are_empty()
{
var db = WithMigrationTestDb(c =>
{
c.Insert.IntoTable("Notifications").Row(new
{
OnGrab = true,
OnDownload = true,
OnUpgrade = true,
OnHealthIssue = true,
IncludeHealthWarnings = true,
OnRename = true,
Name = "Mail Sonarr",
Implementation = "Email",
Tags = "[]",
Settings = new { }.ToJson(),
ConfigContract = "EmailSettings"
});
});
var items = db.Query<NotificationDefinition201>("SELECT * FROM \"Notifications\"");
items.Should().HaveCount(1);
items.First().Implementation.Should().Be("Email");
items.First().ConfigContract.Should().Be("EmailSettings");
items.First().Settings.UseEncryption.Should().Be((int)EmailEncryptionType.Preferred);
}
}
public class NotificationDefinition201
{
public int Id { get; set; }
public string Implementation { get; set; }
public string ConfigContract { get; set; }
public EmailSettings201 Settings { get; set; }
public string Name { get; set; }
public bool OnGrab { get; set; }
public bool OnDownload { get; set; }
public bool OnUpgrade { get; set; }
public bool OnRename { get; set; }
public bool OnSeriesDelete { get; set; }
public bool OnEpisodeFileDelete { get; set; }
public bool OnEpisodeFileDeleteForUpgrade { get; set; }
public bool OnHealthIssue { get; set; }
public bool OnApplicationUpdate { get; set; }
public bool OnManualInteractionRequired { get; set; }
public bool OnSeriesAdd { get; set; }
public bool OnHealthRestored { get; set; }
public bool SupportsOnGrab { get; set; }
public bool SupportsOnDownload { get; set; }
public bool SupportsOnUpgrade { get; set; }
public bool SupportsOnRename { get; set; }
public bool SupportsOnSeriesDelete { get; set; }
public bool SupportsOnEpisodeFileDelete { get; set; }
public bool SupportsOnEpisodeFileDeleteForUpgrade { get; set; }
public bool SupportsOnHealthIssue { get; set; }
public bool IncludeHealthWarnings { get; set; }
public List<int> Tags { get; set; }
}
public class EmailSettings200
{
public string Server { get; set; }
public int Port { get; set; }
public bool RequireEncryption { get; set; }
public string Username { get; set; }
public string Password { get; set; }
public string From { get; set; }
public IEnumerable<string> To { get; set; }
public IEnumerable<string> Cc { get; set; }
public IEnumerable<string> Bcc { get; set; }
}
public class EmailSettings201
{
public string Server { get; set; }
public int Port { get; set; }
public int UseEncryption { get; set; }
public string Username { get; set; }
public string Password { get; set; }
public string From { get; set; }
public IEnumerable<string> To { get; set; }
public IEnumerable<string> Cc { get; set; }
public IEnumerable<string> Bcc { get; set; }
}
}

View File

@@ -454,6 +454,8 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests
[TestCase("0")]
[TestCase("15d")]
[TestCase("")]
[TestCase(null)]
public void should_set_history_removes_completed_downloads_false(string historyRetention)
{
_config.Misc.history_retention = historyRetention;

View File

@@ -176,7 +176,7 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks
public void should_return_ok_on_episode_imported_event()
{
GivenFolderExists(_downloadRootPath);
var importEvent = new EpisodeImportedEvent(new LocalEpisode(), new EpisodeFile(), new List<EpisodeFile>(), true, new DownloadClientItem());
var importEvent = new EpisodeImportedEvent(new LocalEpisode(), new EpisodeFile(), new List<DeletedEpisodeFile>(), true, new DownloadClientItem());
Subject.Check(importEvent).ShouldBeOk();
}

View File

@@ -66,7 +66,7 @@ namespace NzbDrone.Core.Test.HistoryTests
DownloadId = "abcd"
};
Subject.Handle(new EpisodeImportedEvent(localEpisode, episodeFile, new List<EpisodeFile>(), true, downloadClientItem));
Subject.Handle(new EpisodeImportedEvent(localEpisode, episodeFile, new List<DeletedEpisodeFile>(), true, downloadClientItem));
Mocker.GetMock<IHistoryRepository>()
.Verify(v => v.Insert(It.Is<EpisodeHistory>(h => h.SourceTitle == Path.GetFileNameWithoutExtension(localEpisode.Path))));

View File

@@ -0,0 +1,219 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Core.ImportLists;
using NzbDrone.Core.ImportLists.ImportListItems;
using NzbDrone.Core.MetadataSource;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.ImportListTests
{
[TestFixture]
public class FetchAndParseImportListServiceFixture : CoreTest<FetchAndParseImportListService>
{
private List<IImportList> _importLists;
private List<ImportListItemInfo> _listSeries;
[SetUp]
public void Setup()
{
_importLists = new List<IImportList>();
Mocker.GetMock<IImportListFactory>()
.Setup(v => v.AutomaticAddEnabled(It.IsAny<bool>()))
.Returns(_importLists);
_listSeries = Builder<ImportListItemInfo>.CreateListOfSize(5)
.Build().ToList();
Mocker.GetMock<ISearchForNewSeries>()
.Setup(v => v.SearchForNewSeriesByImdbId(It.IsAny<string>()))
.Returns((string value) => new List<Tv.Series>() { new Tv.Series() { ImdbId = value } });
}
private Mock<IImportList> WithList(int id, bool enabled, bool enabledAuto, ImportListFetchResult fetchResult, TimeSpan? minRefresh = null, int? lastSyncOffset = null, int? syncDeletedCount = null)
{
return CreateListResult(id, enabled, enabledAuto, fetchResult, minRefresh, lastSyncOffset, syncDeletedCount);
}
private Mock<IImportList> CreateListResult(int id, bool enabled, bool enabledAuto, ImportListFetchResult fetchResult, TimeSpan? minRefresh = null, int? lastSyncOffset = null, int? syncDeletedCount = null)
{
var refreshInterval = minRefresh ?? TimeSpan.FromHours(12);
var importListDefinition = new ImportListDefinition { Id = id, Enable = enabled, EnableAutomaticAdd = enabledAuto, MinRefreshInterval = refreshInterval };
var mockImportList = new Mock<IImportList>();
mockImportList.SetupGet(s => s.Definition).Returns(importListDefinition);
mockImportList.Setup(s => s.Fetch()).Returns(fetchResult);
mockImportList.SetupGet(s => s.MinRefreshInterval).Returns(refreshInterval);
DateTime? lastSync = lastSyncOffset.HasValue ? DateTime.UtcNow.AddHours(lastSyncOffset.Value) : null;
Mocker.GetMock<IImportListStatusService>()
.Setup(v => v.GetListStatus(id))
.Returns(new ImportListStatus() { LastInfoSync = lastSync });
if (syncDeletedCount.HasValue)
{
Mocker.GetMock<IImportListItemService>()
.Setup(v => v.SyncSeriesForList(It.IsAny<List<ImportListItemInfo>>(), id))
.Returns(syncDeletedCount.Value);
}
_importLists.Add(mockImportList.Object);
return mockImportList;
}
[Test]
public void should_skip_recently_fetched_list()
{
var fetchResult = new ImportListFetchResult();
var list = WithList(1, true, true, fetchResult, lastSyncOffset: 0);
var result = Subject.Fetch();
list.Verify(f => f.Fetch(), Times.Never());
result.Series.Count.Should().Be(0);
result.AnyFailure.Should().BeFalse();
}
[Test]
public void should_skip_recent_and_fetch_good()
{
var fetchResult = new ImportListFetchResult();
var recent = WithList(1, true, true, fetchResult, lastSyncOffset: 0);
var old = WithList(2, true, true, fetchResult);
var result = Subject.Fetch();
recent.Verify(f => f.Fetch(), Times.Never());
old.Verify(f => f.Fetch(), Times.Once());
result.AnyFailure.Should().BeFalse();
}
[Test]
public void should_return_failure_if_single_list_fails()
{
var fetchResult = new ImportListFetchResult { Series = _listSeries, AnyFailure = true };
WithList(1, true, true, fetchResult);
var listResult = Subject.Fetch();
listResult.AnyFailure.Should().BeTrue();
Mocker.GetMock<IImportListStatusService>()
.Verify(v => v.UpdateListSyncStatus(It.IsAny<int>(), It.IsAny<bool>()), Times.Never());
}
[Test]
public void should_return_failure_if_any_list_fails()
{
var fetchResult1 = new ImportListFetchResult { Series = _listSeries, AnyFailure = true };
WithList(1, true, true, fetchResult1);
var fetchResult2 = new ImportListFetchResult { Series = _listSeries, AnyFailure = false };
WithList(2, true, true, fetchResult2);
var listResult = Subject.Fetch();
listResult.AnyFailure.Should().BeTrue();
}
[Test]
public void should_return_early_if_no_available_lists()
{
var listResult = Subject.Fetch();
Mocker.GetMock<IImportListStatusService>()
.Verify(v => v.GetListStatus(It.IsAny<int>()), Times.Never());
listResult.Series.Count.Should().Be(0);
listResult.AnyFailure.Should().BeFalse();
}
[Test]
public void should_store_series_if_list_doesnt_fail()
{
var listId = 1;
var fetchResult = new ImportListFetchResult { Series = _listSeries, AnyFailure = false };
WithList(listId, true, true, fetchResult);
var listResult = Subject.Fetch();
listResult.AnyFailure.Should().BeFalse();
Mocker.GetMock<IImportListStatusService>()
.Verify(v => v.UpdateListSyncStatus(listId, false), Times.Once());
Mocker.GetMock<IImportListItemService>()
.Verify(v => v.SyncSeriesForList(_listSeries, listId), Times.Once());
}
[Test]
public void should_not_store_series_if_list_fails()
{
var listId = 1;
var fetchResult = new ImportListFetchResult { Series = _listSeries, AnyFailure = true };
WithList(listId, true, true, fetchResult);
var listResult = Subject.Fetch();
listResult.AnyFailure.Should().BeTrue();
Mocker.GetMock<IImportListStatusService>()
.Verify(v => v.UpdateListSyncStatus(listId, false), Times.Never());
Mocker.GetMock<IImportListItemService>()
.Verify(v => v.SyncSeriesForList(It.IsAny<List<ImportListItemInfo>>(), listId), Times.Never());
}
[Test]
public void should_only_store_series_for_lists_that_dont_fail()
{
var passedListId = 1;
var fetchResult1 = new ImportListFetchResult { Series = _listSeries, AnyFailure = false };
WithList(passedListId, true, true, fetchResult1);
var failedListId = 2;
var fetchResult2 = new ImportListFetchResult { Series = _listSeries, AnyFailure = true };
WithList(failedListId, true, true, fetchResult2);
var listResult = Subject.Fetch();
listResult.AnyFailure.Should().BeTrue();
Mocker.GetMock<IImportListStatusService>()
.Verify(v => v.UpdateListSyncStatus(passedListId, false), Times.Once());
Mocker.GetMock<IImportListItemService>()
.Verify(v => v.SyncSeriesForList(_listSeries, passedListId), Times.Once());
Mocker.GetMock<IImportListStatusService>()
.Verify(v => v.UpdateListSyncStatus(failedListId, false), Times.Never());
Mocker.GetMock<IImportListItemService>()
.Verify(v => v.SyncSeriesForList(It.IsAny<List<ImportListItemInfo>>(), failedListId), Times.Never());
}
[Test]
public void should_return_all_results_for_all_lists()
{
var passedListId = 1;
var fetchResult1 = new ImportListFetchResult { Series = _listSeries, AnyFailure = false };
WithList(passedListId, true, true, fetchResult1);
var secondListId = 2;
var fetchResult2 = new ImportListFetchResult { Series = _listSeries, AnyFailure = false };
WithList(secondListId, true, true, fetchResult2);
var listResult = Subject.Fetch();
listResult.AnyFailure.Should().BeFalse();
listResult.Series.Count.Should().Be(5);
}
[Test]
public void should_set_removed_flag_if_list_has_removed_items()
{
var listId = 1;
var fetchResult = new ImportListFetchResult { Series = _listSeries, AnyFailure = false };
WithList(listId, true, true, fetchResult, syncDeletedCount: 500);
var result = Subject.Fetch();
result.AnyFailure.Should().BeFalse();
Mocker.GetMock<IImportListStatusService>()
.Verify(v => v.UpdateListSyncStatus(listId, true), Times.Once());
}
}
}

View File

@@ -0,0 +1,57 @@
using System.Collections.Generic;
using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Core.ImportLists.ImportListItems;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.ImportListTests
{
public class ImportListItemServiceFixture : CoreTest<ImportListItemService>
{
[SetUp]
public void SetUp()
{
var existing = Builder<ImportListItemInfo>.CreateListOfSize(3)
.TheFirst(1)
.With(s => s.TvdbId = 6)
.With(s => s.ImdbId = "6")
.TheNext(1)
.With(s => s.TvdbId = 7)
.With(s => s.ImdbId = "7")
.TheNext(1)
.With(s => s.TvdbId = 8)
.With(s => s.ImdbId = "8")
.Build().ToList();
Mocker.GetMock<IImportListItemInfoRepository>()
.Setup(v => v.GetAllForLists(It.IsAny<List<int>>()))
.Returns(existing);
}
[Test]
public void should_insert_new_update_existing_and_delete_missing()
{
var newItems = Builder<ImportListItemInfo>.CreateListOfSize(3)
.TheFirst(1)
.With(s => s.TvdbId = 5)
.TheNext(1)
.With(s => s.TvdbId = 6)
.TheNext(1)
.With(s => s.TvdbId = 7)
.Build().ToList();
var numDeleted = Subject.SyncSeriesForList(newItems, 1);
numDeleted.Should().Be(1);
Mocker.GetMock<IImportListItemInfoRepository>()
.Verify(v => v.InsertMany(It.Is<List<ImportListItemInfo>>(s => s.Count == 1 && s[0].TvdbId == 5)), Times.Once());
Mocker.GetMock<IImportListItemInfoRepository>()
.Verify(v => v.UpdateMany(It.Is<List<ImportListItemInfo>>(s => s.Count == 2 && s[0].TvdbId == 6 && s[1].TvdbId == 7)), Times.Once());
Mocker.GetMock<IImportListItemInfoRepository>()
.Verify(v => v.DeleteMany(It.Is<List<ImportListItemInfo>>(s => s.Count == 1 && s[0].TvdbId == 8)), Times.Once());
}
}
}

View File

@@ -1,9 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FizzWare.NBuilder;
using Moq;
using NUnit.Framework;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.ImportLists;
using NzbDrone.Core.ImportLists.Exclusions;
using NzbDrone.Core.ImportLists.ImportListItems;
using NzbDrone.Core.MetadataSource;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Test.Framework;
@@ -13,17 +17,61 @@ namespace NzbDrone.Core.Test.ImportListTests
{
public class ImportListSyncServiceFixture : CoreTest<ImportListSyncService>
{
private List<ImportListItemInfo> _importListReports;
private ImportListFetchResult _importListFetch;
private List<ImportListItemInfo> _list1Series;
private List<ImportListItemInfo> _list2Series;
private List<Series> _existingSeries;
private List<IImportList> _importLists;
private ImportListSyncCommand _commandAll;
private ImportListSyncCommand _commandSingle;
[SetUp]
public void SetUp()
{
var importListItem1 = new ImportListItemInfo
_importLists = new List<IImportList>();
var item1 = new ImportListItemInfo()
{
Title = "Breaking Bad"
};
_importListReports = new List<ImportListItemInfo> { importListItem1 };
_list1Series = new List<ImportListItemInfo>() { item1 };
_existingSeries = Builder<Series>.CreateListOfSize(3)
.TheFirst(1)
.With(s => s.TvdbId = 6)
.With(s => s.ImdbId = "6")
.TheNext(1)
.With(s => s.TvdbId = 7)
.With(s => s.ImdbId = "7")
.TheNext(1)
.With(s => s.TvdbId = 8)
.With(s => s.ImdbId = "8")
.Build().ToList();
_list2Series = Builder<ImportListItemInfo>.CreateListOfSize(3)
.TheFirst(1)
.With(s => s.TvdbId = 6)
.With(s => s.ImdbId = "6")
.TheNext(1)
.With(s => s.TvdbId = 7)
.With(s => s.ImdbId = "7")
.TheNext(1)
.With(s => s.TvdbId = 8)
.With(s => s.ImdbId = "8")
.Build().ToList();
_importListFetch = new ImportListFetchResult(_list1Series, false);
_commandAll = new ImportListSyncCommand
{
};
_commandSingle = new ImportListSyncCommand
{
DefinitionId = 1
};
var mockImportList = new Mock<IImportList>();
@@ -31,6 +79,10 @@ namespace NzbDrone.Core.Test.ImportListTests
.Setup(v => v.AllSeriesTvdbIds())
.Returns(new List<int>());
Mocker.GetMock<ISeriesService>()
.Setup(v => v.GetAllSeries())
.Returns(_existingSeries);
Mocker.GetMock<ISearchForNewSeries>()
.Setup(v => v.SearchForNewSeries(It.IsAny<string>()))
.Returns(new List<Series>());
@@ -41,15 +93,19 @@ namespace NzbDrone.Core.Test.ImportListTests
Mocker.GetMock<IImportListFactory>()
.Setup(v => v.All())
.Returns(new List<ImportListDefinition> { new ImportListDefinition { ShouldMonitor = MonitorTypes.All } });
.Returns(() => _importLists.Select(x => x.Definition as ImportListDefinition).ToList());
Mocker.GetMock<IImportListFactory>()
.Setup(v => v.GetAvailableProviders())
.Returns(_importLists);
Mocker.GetMock<IImportListFactory>()
.Setup(v => v.AutomaticAddEnabled(It.IsAny<bool>()))
.Returns(new List<IImportList> { mockImportList.Object });
.Returns(() => _importLists.Where(x => (x.Definition as ImportListDefinition).EnableAutomaticAdd).ToList());
Mocker.GetMock<IFetchAndParseImportList>()
.Setup(v => v.Fetch())
.Returns(_importListReports);
.Returns(_importListFetch);
Mocker.GetMock<IImportListExclusionService>()
.Setup(v => v.All())
@@ -58,19 +114,19 @@ namespace NzbDrone.Core.Test.ImportListTests
private void WithTvdbId()
{
_importListReports.First().TvdbId = 81189;
_list1Series.First().TvdbId = 81189;
}
private void WithImdbId()
{
_importListReports.First().ImdbId = "tt0496424";
_list1Series.First().ImdbId = "tt0496424";
}
private void WithExistingSeries()
{
Mocker.GetMock<ISeriesService>()
.Setup(v => v.AllSeriesTvdbIds())
.Returns(new List<int> { _importListReports.First().TvdbId });
.Returns(new List<int> { _list1Series.First().TvdbId });
}
private void WithExcludedSeries()
@@ -81,22 +137,281 @@ namespace NzbDrone.Core.Test.ImportListTests
{
new ImportListExclusion
{
TvdbId = 81189
TvdbId = _list1Series.First().TvdbId
}
});
}
private void WithMonitorType(MonitorTypes monitor)
{
_importLists.ForEach(li => (li.Definition as ImportListDefinition).ShouldMonitor = monitor);
}
private void WithCleanLevel(ListSyncLevelType cleanLevel, int? tagId = null)
{
Mocker.GetMock<IConfigService>()
.SetupGet(v => v.ListSyncLevel)
.Returns(cleanLevel);
if (tagId.HasValue)
{
Mocker.GetMock<IConfigService>()
.SetupGet(v => v.ListSyncTag)
.Returns(tagId.Value);
}
}
private void WithList(int id, bool enabledAuto, int lastSyncHoursOffset = 0, bool pendingRemovals = true, DateTime? disabledTill = null)
{
var importListDefinition = new ImportListDefinition { Id = id, EnableAutomaticAdd = enabledAuto };
Mocker.GetMock<IImportListFactory>()
.Setup(v => v.All())
.Returns(new List<ImportListDefinition> { new ImportListDefinition { ShouldMonitor = monitor } });
.Setup(v => v.Get(id))
.Returns(importListDefinition);
var mockImportList = new Mock<IImportList>();
mockImportList.SetupGet(s => s.Definition).Returns(importListDefinition);
mockImportList.SetupGet(s => s.MinRefreshInterval).Returns(TimeSpan.FromHours(12));
var status = new ImportListStatus()
{
LastInfoSync = DateTime.UtcNow.AddHours(lastSyncHoursOffset),
HasRemovedItemSinceLastClean = pendingRemovals,
DisabledTill = disabledTill
};
if (disabledTill.HasValue)
{
_importListFetch.AnyFailure = true;
}
Mocker.GetMock<IImportListStatusService>()
.Setup(v => v.GetListStatus(id))
.Returns(status);
_importLists.Add(mockImportList.Object);
}
private void VerifyDidAddTag(int expectedSeriesCount, int expectedTagId)
{
Mocker.GetMock<ISeriesService>()
.Verify(v => v.UpdateSeries(It.Is<List<Series>>(x => x.Count == expectedSeriesCount && x.All(series => series.Tags.Contains(expectedTagId))), true), Times.Once());
}
[Test]
public void should_not_clean_library_if_lists_have_not_removed_any_items()
{
_importListFetch.Series = _existingSeries.Select(x => new ImportListItemInfo() { TvdbId = x.TvdbId }).ToList();
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true, pendingRemovals: false);
WithCleanLevel(ListSyncLevelType.KeepAndUnmonitor);
Subject.Execute(_commandAll);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.GetAllSeries(), Times.Never());
Mocker.GetMock<ISeriesService>()
.Verify(v => v.UpdateSeries(It.IsAny<List<Series>>(), true), Times.Never());
}
[Test]
public void should_not_clean_library_if_config_value_disable()
{
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithCleanLevel(ListSyncLevelType.Disabled);
Subject.Execute(_commandAll);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.GetAllSeries(), Times.Never());
Mocker.GetMock<ISeriesService>()
.Verify(v => v.UpdateSeries(new List<Series>(), true), Times.Never());
}
[Test]
public void should_log_only_on_clean_library_if_config_value_logonly()
{
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithCleanLevel(ListSyncLevelType.LogOnly);
Subject.Execute(_commandAll);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.GetAllSeries(), Times.Once());
Mocker.GetMock<ISeriesService>()
.Verify(v => v.DeleteSeries(It.IsAny<List<int>>(), It.IsAny<bool>(), It.IsAny<bool>()), Times.Never());
Mocker.GetMock<ISeriesService>()
.Verify(v => v.UpdateSeries(new List<Series>(), true), Times.Once());
}
[Test]
public void should_unmonitor_on_clean_library_if_config_value_keepAndUnmonitor()
{
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithCleanLevel(ListSyncLevelType.KeepAndUnmonitor);
var monitored = _existingSeries.Count(x => x.Monitored);
Subject.Execute(_commandAll);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.GetAllSeries(), Times.Once());
Mocker.GetMock<ISeriesService>()
.Verify(v => v.DeleteSeries(It.IsAny<List<int>>(), It.IsAny<bool>(), It.IsAny<bool>()), Times.Never());
Mocker.GetMock<ISeriesService>()
.Verify(v => v.UpdateSeries(It.Is<List<Series>>(s => s.Count == monitored && s.All(m => !m.Monitored)), true), Times.Once());
}
[Test]
public void should_not_clean_on_clean_library_if_tvdb_match()
{
WithList(1, true);
WithCleanLevel(ListSyncLevelType.KeepAndUnmonitor);
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
Mocker.GetMock<IImportListItemService>()
.Setup(v => v.Exists(6, It.IsAny<string>()))
.Returns(true);
Subject.Execute(_commandAll);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.UpdateSeries(It.Is<List<Series>>(s => s.Count > 0 && s.All(m => !m.Monitored)), true), Times.Once());
}
[Test]
public void should_not_clean_on_clean_library_if_imdb_match()
{
WithList(1, true);
WithCleanLevel(ListSyncLevelType.KeepAndUnmonitor);
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
var x = _importLists;
Mocker.GetMock<IImportListItemService>()
.Setup(v => v.Exists(It.IsAny<int>(), "6"))
.Returns(true);
Subject.Execute(_commandAll);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.UpdateSeries(It.Is<List<Series>>(s => s.Count > 0 && s.All(m => !m.Monitored)), true), Times.Once());
}
[Test]
public void should_tag_series_on_clean_library_if_config_value_keepAndTag()
{
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithCleanLevel(ListSyncLevelType.KeepAndTag, 1);
Subject.Execute(_commandAll);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.GetAllSeries(), Times.Once());
VerifyDidAddTag(_existingSeries.Count, 1);
}
[Test]
public void should_not_clean_if_list_failures()
{
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true, disabledTill: DateTime.UtcNow.AddHours(1));
WithCleanLevel(ListSyncLevelType.LogOnly);
Subject.Execute(_commandAll);
Mocker.GetMock<ISeriesService>()
.Verify(v => v.GetAllSeries(), Times.Never());
Mocker.GetMock<ISeriesService>()
.Verify(v => v.UpdateSeries(It.IsAny<List<Series>>(), It.IsAny<bool>()), Times.Never());
Mocker.GetMock<ISeriesService>()
.Verify(v => v.DeleteSeries(It.IsAny<List<int>>(), It.IsAny<bool>(), It.IsAny<bool>()), Times.Never());
}
[Test]
public void should_add_new_series_from_single_list_to_library()
{
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithCleanLevel(ListSyncLevelType.Disabled);
Subject.Execute(_commandAll);
Mocker.GetMock<IAddSeriesService>()
.Verify(v => v.AddSeries(It.Is<List<Series>>(s => s.Count == 1), true), Times.Once());
}
[Test]
public void should_add_new_series_from_multiple_list_to_library()
{
_list2Series.ForEach(m => m.ImportListId = 2);
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
_importListFetch.Series.AddRange(_list2Series);
WithList(1, true);
WithList(2, true);
WithCleanLevel(ListSyncLevelType.Disabled);
Subject.Execute(_commandAll);
Mocker.GetMock<IAddSeriesService>()
.Verify(v => v.AddSeries(It.Is<List<Series>>(s => s.Count == 4), true), Times.Once());
}
[Test]
public void should_add_new_series_to_library_only_from_enabled_lists()
{
_list2Series.ForEach(m => m.ImportListId = 2);
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
_importListFetch.Series.AddRange(_list2Series);
WithList(1, true);
WithList(2, false);
WithCleanLevel(ListSyncLevelType.Disabled);
Subject.Execute(_commandAll);
Mocker.GetMock<IAddSeriesService>()
.Verify(v => v.AddSeries(It.Is<List<Series>>(s => s.Count == 1), true), Times.Once());
}
[Test]
public void should_not_add_duplicate_series_from_seperate_lists()
{
_list2Series.ForEach(m => m.ImportListId = 2);
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
_importListFetch.Series.AddRange(_list2Series);
_importListFetch.Series[0].TvdbId = 6;
WithList(1, true);
WithList(2, true);
WithCleanLevel(ListSyncLevelType.Disabled);
Subject.Execute(_commandAll);
Mocker.GetMock<IAddSeriesService>()
.Verify(v => v.AddSeries(It.Is<List<Series>>(s => s.Count == 3), true), Times.Once());
}
[Test]
public void should_search_if_series_title_and_no_series_id()
{
Subject.Execute(new ImportListSyncCommand());
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
Subject.Execute(_commandAll);
Mocker.GetMock<ISearchForNewSeries>()
.Verify(v => v.SearchForNewSeries(It.IsAny<string>()), Times.Once());
@@ -105,8 +420,10 @@ namespace NzbDrone.Core.Test.ImportListTests
[Test]
public void should_not_search_if_series_title_and_series_id()
{
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithTvdbId();
Subject.Execute(new ImportListSyncCommand());
Subject.Execute(_commandAll);
Mocker.GetMock<ISearchForNewSeries>()
.Verify(v => v.SearchForNewSeries(It.IsAny<string>()), Times.Never());
@@ -115,8 +432,10 @@ namespace NzbDrone.Core.Test.ImportListTests
[Test]
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(new ImportListSyncCommand());
Subject.Execute(_commandAll);
Mocker.GetMock<ISearchForNewSeries>()
.Verify(v => v.SearchForNewSeriesByImdbId(It.IsAny<string>()), Times.Once());
@@ -125,10 +444,12 @@ namespace NzbDrone.Core.Test.ImportListTests
[Test]
public void should_not_add_if_existing_series()
{
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithTvdbId();
WithExistingSeries();
Subject.Execute(new ImportListSyncCommand());
Subject.Execute(_commandAll);
Mocker.GetMock<IAddSeriesService>()
.Verify(v => v.AddSeries(It.Is<List<Series>>(t => t.Count == 0), It.IsAny<bool>()));
@@ -138,10 +459,12 @@ namespace NzbDrone.Core.Test.ImportListTests
[TestCase(MonitorTypes.All, true)]
public void should_add_if_not_existing_series(MonitorTypes monitor, bool expectedSeriesMonitored)
{
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithTvdbId();
WithMonitorType(monitor);
Subject.Execute(new ImportListSyncCommand());
Subject.Execute(_commandAll);
Mocker.GetMock<IAddSeriesService>()
.Verify(v => v.AddSeries(It.Is<List<Series>>(t => t.Count == 1 && t.First().Monitored == expectedSeriesMonitored), It.IsAny<bool>()));
@@ -150,10 +473,12 @@ namespace NzbDrone.Core.Test.ImportListTests
[Test]
public void should_not_add_if_excluded_series()
{
_importListFetch.Series.ForEach(m => m.ImportListId = 1);
WithList(1, true);
WithTvdbId();
WithExcludedSeries();
Subject.Execute(new ImportListSyncCommand());
Subject.Execute(_commandAll);
Mocker.GetMock<IAddSeriesService>()
.Verify(v => v.AddSeries(It.Is<List<Series>>(t => t.Count == 0), It.IsAny<bool>()));
@@ -177,7 +502,7 @@ namespace NzbDrone.Core.Test.ImportListTests
{
Mocker.GetMock<IFetchAndParseImportList>()
.Setup(v => v.Fetch())
.Returns(new List<ImportListItemInfo>());
.Returns(new ImportListFetchResult());
Subject.Execute(new ImportListSyncCommand());

View File

@@ -33,7 +33,7 @@ namespace NzbDrone.Core.Test.IndexerTests.FileListTests
Mocker.GetMock<IHttpClient>()
.Setup(o => o.ExecuteAsync(It.Is<HttpRequest>(v => v.Method == HttpMethod.Get)))
.Returns<HttpRequest>(r => Task.FromResult(new HttpResponse(r, new HttpHeader(), recentFeed)));
.Returns<HttpRequest>(r => Task.FromResult(new HttpResponse(r, new HttpHeader { { "Content-Type", "application/json" } }, recentFeed)));
var releases = await Subject.FetchRecent();

View File

@@ -186,6 +186,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
Path.Combine(_series.Path, "Scenes", "file6.mkv").AsOsAgnostic(),
Path.Combine(_series.Path, "Shorts", "file7.mkv").AsOsAgnostic(),
Path.Combine(_series.Path, "Trailers", "file8.mkv").AsOsAgnostic(),
Path.Combine(_series.Path, "Other", "file9.mkv").AsOsAgnostic(),
Path.Combine(_series.Path, "Series Title S01E01 (1080p BluRay x265 10bit Tigole).mkv").AsOsAgnostic(),
});

View File

@@ -0,0 +1,48 @@
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.MediaFiles.EpisodeImport.Aggregation.Aggregators;
using NzbDrone.Core.Test.Framework;
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)
{
var episodeFile = new EpisodeFile
{
RelativePath = relativePath,
OriginalFilePath = originalFilePath
};
var subtitleTitleInfo = Subject.CleanSubtitleTitleInfo(episodeFile, path);
subtitleTitleInfo.Title.Should().BeNull();
subtitleTitleInfo.Copy.Should().Be(0);
}
[TestCase("Default (2020)/Season 1/Default (2020) - S01E20 - [AAC 2.0].mkv", "Default (2020) - S01E20 - [AAC 2.0].default.eng.forced.ass")]
[TestCase("Default (2020)/Season 1/Default (2020) - S01E20 - [AAC 2.0].mkv", "Default (2020) - S01E20 - [AAC 2.0].eng.default.ass")]
[TestCase("Default (2020)/Season 1/Default (2020) - S01E20 - [AAC 2.0].mkv", "Default (2020) - S01E20 - [AAC 2.0].default.eng.testtitle.forced.ass")]
[TestCase("Default (2020)/Season 1/Default (2020) - S01E20 - [AAC 2.0].mkv", "Default (2020) - S01E20 - [AAC 2.0].testtitle.eng.default.ass")]
public void should_not_parse_default(string relativePath, string path)
{
var episodeFile = new EpisodeFile
{
RelativePath = relativePath
};
var subtitleTitleInfo = Subject.CleanSubtitleTitleInfo(episodeFile, path);
subtitleTitleInfo.LanguageTags.Should().NotContain("default");
}
}
}

View File

@@ -25,7 +25,7 @@ namespace NzbDrone.Core.Test.NotificationTests.EmailTests
_emailSettings = Builder<EmailSettings>.CreateNew()
.With(s => s.Server = "someserver")
.With(s => s.Port = 567)
.With(s => s.RequireEncryption = true)
.With(s => s.UseEncryption = (int)EmailEncryptionType.Always)
.With(s => s.From = "dont@email.me")
.With(s => s.To = new string[] { "dont@email.me" })
.Build();

View File

@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using Moq;
using NUnit.Framework;
using NzbDrone.Core.MediaFiles;
@@ -33,16 +33,16 @@ namespace NzbDrone.Core.Test.NotificationTests
RelativePath = "file1.S01E01E02.mkv"
},
OldFiles = new List<EpisodeFile>
OldFiles = new List<DeletedEpisodeFile>
{
new EpisodeFile
new DeletedEpisodeFile(new EpisodeFile
{
RelativePath = "file1.S01E01.mkv"
},
new EpisodeFile
}, null),
new DeletedEpisodeFile(new EpisodeFile
{
RelativePath = "file1.S01E02.mkv"
}
}, null)
}
};

View File

@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using FizzWare.NBuilder;
using Moq;
@@ -28,7 +28,7 @@ namespace NzbDrone.Core.Test.NotificationTests.Xbmc
_downloadMessage = Builder<DownloadMessage>.CreateNew()
.With(d => d.Series = series)
.With(d => d.EpisodeFile = episodeFile)
.With(d => d.OldFiles = new List<EpisodeFile>())
.With(d => d.OldFiles = new List<DeletedEpisodeFile>())
.Build();
Subject.Definition = new NotificationDefinition();
@@ -40,9 +40,12 @@ namespace NzbDrone.Core.Test.NotificationTests.Xbmc
private void GivenOldFiles()
{
_downloadMessage.OldFiles = Builder<EpisodeFile>.CreateListOfSize(1)
.Build()
.ToList();
_downloadMessage.OldFiles = Builder<DeletedEpisodeFile>
.CreateListOfSize(1)
.All()
.WithFactory(() => new DeletedEpisodeFile(Builder<EpisodeFile>.CreateNew().Build(), null))
.Build()
.ToList();
Subject.Definition.Settings = new XbmcSettings
{

View File

@@ -0,0 +1,94 @@
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 TruncatedReleaseGroupFixture : CoreTest<FileNameBuilder>
{
private Series _series;
private List<Episode> _episodes;
private EpisodeFile _episodeFile;
private NamingConfig _namingConfig;
[SetUp]
public void Setup()
{
_series = Builder<Series>
.CreateNew()
.With(s => s.Title = "Series Title")
.Build();
_namingConfig = NamingConfig.Default;
_namingConfig.MultiEpisodeStyle = 0;
_namingConfig.RenameEpisodes = true;
Mocker.GetMock<INamingConfigService>()
.Setup(c => c.GetConfig()).Returns(_namingConfig);
_episodes = new List<Episode>
{
Builder<Episode>.CreateNew()
.With(e => e.Title = "Episode Title 1")
.With(e => e.SeasonNumber = 1)
.With(e => e.EpisodeNumber = 1)
.Build()
};
_episodeFile = new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p), ReleaseGroup = "SonarrTest" };
Mocker.GetMock<IQualityDefinitionService>()
.Setup(v => v.Get(Moq.It.IsAny<Quality>()))
.Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v));
Mocker.GetMock<ICustomFormatService>()
.Setup(v => v.All())
.Returns(new List<CustomFormat>());
}
private void GivenProper()
{
_episodeFile.Quality.Revision.Version = 2;
}
[Test]
public void should_truncate_from_beginning()
{
_series.Title = "The Fantastic Life of Mr. Sisko";
_episodeFile.Quality.Quality = Quality.Bluray1080p;
_episodeFile.ReleaseGroup = "IWishIWasALittleBitTallerIWishIWasABallerIWishIHadAGirlWhoLookedGoodIWouldCallHerIWishIHadARabbitInAHatWithABatAndASixFourImpala";
_episodes = _episodes.Take(1).ToList();
_namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}-{ReleaseGroup:12}";
var result = Subject.BuildFileName(_episodes, _series, _episodeFile, ".mkv");
result.Length.Should().BeLessOrEqualTo(255);
result.Should().Be("The Fantastic Life of Mr. Sisko - S01E01 - Episode Title 1 Bluray-1080p-IWishIWas....mkv");
}
[Test]
public void should_truncate_from_from_end()
{
_series.Title = "The Fantastic Life of Mr. Sisko";
_episodeFile.Quality.Quality = Quality.Bluray1080p;
_episodeFile.ReleaseGroup = "IWishIWasALittleBitTallerIWishIWasABallerIWishIHadAGirlWhoLookedGoodIWouldCallHerIWishIHadARabbitInAHatWithABatAndASixFourImpala";
_episodes = _episodes.Take(1).ToList();
_namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}-{ReleaseGroup:-17}";
var result = Subject.BuildFileName(_episodes, _series, _episodeFile, ".mkv");
result.Length.Should().BeLessOrEqualTo(255);
result.Should().Be("The Fantastic Life of Mr. Sisko - S01E01 - Episode Title 1 Bluray-1080p-...ASixFourImpala.mkv");
}
}
}

View File

@@ -0,0 +1,57 @@
using System.Collections.Generic;
using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.CustomFormats;
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 TruncatedSeriesTitleFixture : CoreTest<FileNameBuilder>
{
private Series _series;
private NamingConfig _namingConfig;
[SetUp]
public void Setup()
{
_series = Builder<Series>
.CreateNew()
.With(s => s.Title = "Series Title")
.Build();
_namingConfig = NamingConfig.Default;
_namingConfig.MultiEpisodeStyle = 0;
_namingConfig.RenameEpisodes = true;
Mocker.GetMock<INamingConfigService>()
.Setup(c => c.GetConfig()).Returns(_namingConfig);
Mocker.GetMock<IQualityDefinitionService>()
.Setup(v => v.Get(Moq.It.IsAny<Quality>()))
.Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v));
Mocker.GetMock<ICustomFormatService>()
.Setup(v => v.All())
.Returns(new List<CustomFormat>());
}
[TestCase("{Series Title:16}", "The Fantastic...")]
[TestCase("{Series TitleThe:17}", "Fantastic Life...")]
[TestCase("{Series CleanTitle:-13}", "...Mr. Sisko")]
public void should_truncate_series_title(string format, string expected)
{
_series.Title = "The Fantastic Life of Mr. Sisko";
_namingConfig.SeriesFolderFormat = format;
var result = Subject.GetSeriesFolder(_series, _namingConfig);
result.Should().Be(expected);
}
}
}

View File

@@ -128,6 +128,10 @@ namespace NzbDrone.Core.Test.ParserTests
[TestCase("Different show 1. Bölüm (23.10.2023) 720p WebDL AAC H.264 - TURG", "Different show", 1, 0, 0)]
[TestCase("Dubbed show 79.BLM Sezon Finali(25.06.2023) 720p WEB-DL AAC2.0 H.264-TURG", "Dubbed show", 79, 0, 0)]
[TestCase("Exclusive BLM Documentary with no false positives EP03.1080p.AAC.x264", "Exclusive BLM Documentary with no false positives", 3, 0, 0)]
[TestCase("[SubsPlease] Title de Series S2 - 03 (540p) [63501322]", "Title de Series S2", 3, 0, 0)]
[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("", "", 0, 0, 0)]
public void should_parse_absolute_numbers(string postTitle, string title, int absoluteEpisodeNumber, int seasonNumber, int episodeNumber)

View File

@@ -428,5 +428,38 @@ namespace NzbDrone.Core.Test.ParserTests
result.Languages.Should().Contain(Language.Original);
result.Languages.Should().Contain(Language.English);
}
[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")]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].testtitle.forced.eng.ass", new[] { "forced" }, "testtitle", "English")]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].eng.forced.testtitle.ass", new[] { "forced" }, "testtitle", "English")]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].forced.eng.testtitle.ass", new[] { "forced" }, "testtitle", "English")]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].testtitle.default.fra.forced.ass", new[] { "default", "forced" }, "testtitle", "French")]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].fra.default.testtitle.forced.ass", new[] { "default", "forced" }, "testtitle", "French")]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].default.fra.testtitle.forced.ass", new[] { "default", "forced" }, "testtitle", "French")]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].testtitle.forced.fra.ass", new[] { "forced" }, "testtitle", "French")]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].fra.forced.testtitle.ass", new[] { "forced" }, "testtitle", "French")]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].forced.fra.testtitle.ass", new[] { "forced" }, "testtitle", "French")]
public void should_parse_title_and_tags(string postTitle, string[] expectedTags, string expectedTitle, string expectedLanguage)
{
var subtitleTitleInfo = LanguageParser.ParseSubtitleLanguageInformation(postTitle);
subtitleTitleInfo.LanguageTags.Should().BeEquivalentTo(expectedTags);
subtitleTitleInfo.Title.Should().BeEquivalentTo(expectedTitle);
subtitleTitleInfo.Language.Should().BeEquivalentTo((Language)expectedLanguage);
}
[TestCase("Name (2020) - S01E20 - [AAC 2.0].default.forced.ass")]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].default.ass")]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].ass")]
[TestCase("Name (2020) - S01E20 - [AAC 2.0].testtitle.ass")]
public void should_not_parse_false_title(string postTitle)
{
var subtitleTitleInfo = LanguageParser.ParseSubtitleLanguageInformation(postTitle);
subtitleTitleInfo.Language.Should().Be(Language.Unknown);
subtitleTitleInfo.LanguageTags.Should().BeEmpty();
subtitleTitleInfo.RawTitle.Should().BeNull();
}
}
}

View File

@@ -83,6 +83,7 @@ namespace NzbDrone.Core.Test.ParserTests
[TestCase("Series Title (2012) - S01E01 - Episode 1 (1080p BluRay x265 r00t).mkv", "r00t")]
[TestCase("Series Title - S01E01 - Girls Gone Wild Exposed (720p x265 EDGE2020).mkv", "EDGE2020")]
[TestCase("Series.Title.S01E02.1080p.BluRay.Remux.AVC.FLAC.2.0-E.N.D", "E.N.D")]
[TestCase("Show Name (2016) Season 1 S01 (1080p AMZN WEB-DL x265 HEVC 10bit EAC3 5 1 RZeroX) QxR", "RZeroX")]
public void should_parse_exception_release_group(string title, string expected)
{
Parser.Parser.ParseReleaseGroup(title).Should().Be(expected);

View File

@@ -23,7 +23,7 @@ namespace NzbDrone.Core.AutoTagging.Specifications
public override int Order => 1;
public override string ImplementationName => "Genre";
[FieldDefinition(1, Label = "Genre(s)", Type = FieldType.Tag)]
[FieldDefinition(1, Label = "AutoTaggingSpecificationGenre", Type = FieldType.Tag)]
public IEnumerable<string> Value { get; set; }
protected override bool IsSatisfiedByWithoutNegate(Series series)

View File

@@ -21,7 +21,7 @@ namespace NzbDrone.Core.AutoTagging.Specifications
public override int Order => 1;
public override string ImplementationName => "Original Language";
[FieldDefinition(1, Label = "Language", Type = FieldType.Select, SelectOptions = typeof(OriginalLanguageFieldConverter))]
[FieldDefinition(1, Label = "AutoTaggingSpecificationOriginalLanguage", Type = FieldType.Select, SelectOptions = typeof(OriginalLanguageFieldConverter))]
public int Value { get; set; }
protected override bool IsSatisfiedByWithoutNegate(Series series)

View File

@@ -20,7 +20,7 @@ namespace NzbDrone.Core.AutoTagging.Specifications
public override int Order => 1;
public override string ImplementationName => "Quality Profile";
[FieldDefinition(1, Label = "Quality Profile", Type = FieldType.QualityProfile)]
[FieldDefinition(1, Label = "AutoTaggingSpecificationQualityProfile", Type = FieldType.QualityProfile)]
public int Value { get; set; }
protected override bool IsSatisfiedByWithoutNegate(Series series)

View File

@@ -22,7 +22,7 @@ namespace NzbDrone.Core.AutoTagging.Specifications
public override int Order => 1;
public override string ImplementationName => "Root Folder";
[FieldDefinition(1, Label = "Root Folder", Type = FieldType.RootFolder)]
[FieldDefinition(1, Label = "AutoTaggingSpecificationRootFolder", Type = FieldType.RootFolder)]
public string Value { get; set; }
protected override bool IsSatisfiedByWithoutNegate(Series series)

View File

@@ -20,7 +20,7 @@ namespace NzbDrone.Core.AutoTagging.Specifications
public override int Order => 2;
public override string ImplementationName => "Series Type";
[FieldDefinition(1, Label = "Series Type", Type = FieldType.Select, SelectOptions = typeof(SeriesTypes))]
[FieldDefinition(1, Label = "AutoTaggingSpecificationSeriesType", Type = FieldType.Select, SelectOptions = typeof(SeriesTypes))]
public int Value { get; set; }
protected override bool IsSatisfiedByWithoutNegate(Series series)

View File

@@ -16,7 +16,7 @@ namespace NzbDrone.Core.AutoTagging.Specifications
public override int Order => 1;
public override string ImplementationName => "Status";
[FieldDefinition(1, Label = "Status", Type = FieldType.Select, SelectOptions = typeof(SeriesStatusType))]
[FieldDefinition(1, Label = "AutoTaggingSpecificationStatus", Type = FieldType.Select, SelectOptions = typeof(SeriesStatusType))]
public int Status { get; set; }
protected override bool IsSatisfiedByWithoutNegate(Series series)

View File

@@ -23,10 +23,10 @@ namespace NzbDrone.Core.AutoTagging.Specifications
public override int Order => 1;
public override string ImplementationName => "Year";
[FieldDefinition(1, Label = "Minimum Year", Type = FieldType.Number)]
[FieldDefinition(1, Label = "AutoTaggingSpecificationMinimumYear", Type = FieldType.Number)]
public int Min { get; set; }
[FieldDefinition(2, Label = "Maximum Year", Type = FieldType.Number)]
[FieldDefinition(2, Label = "AutoTaggingSpecificationMaximumYear", Type = FieldType.Number)]
public int Max { get; set; }
protected override bool IsSatisfiedByWithoutNegate(Series series)

View File

@@ -6,6 +6,7 @@ using NLog;
using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.Http.Proxy;
using NzbDrone.Core.Configuration.Events;
using NzbDrone.Core.ImportLists;
using NzbDrone.Core.Languages;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.MediaFiles.EpisodeImport;
@@ -276,6 +277,18 @@ namespace NzbDrone.Core.Configuration
set { SetValue("ChownGroup", value); }
}
public ListSyncLevelType ListSyncLevel
{
get { return GetValueEnum("ListSyncLevel", ListSyncLevelType.Disabled); }
set { SetValue("ListSyncLevel", value); }
}
public int ListSyncTag
{
get { return GetValueInt("ListSyncTag"); }
set { SetValue("ListSyncTag", value); }
}
public int FirstDayOfWeek
{
get { return GetValueInt("FirstDayOfWeek", (int)CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek); }

View File

@@ -1,5 +1,6 @@
using System.Collections.Generic;
using NzbDrone.Common.Http.Proxy;
using NzbDrone.Core.ImportLists;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.MediaFiles.EpisodeImport;
using NzbDrone.Core.Qualities;
@@ -52,6 +53,9 @@ namespace NzbDrone.Core.Configuration
int MaximumSize { get; set; }
int MinimumAge { get; set; }
ListSyncLevelType ListSyncLevel { get; set; }
int ListSyncTag { get; set; }
// UI
int FirstDayOfWeek { get; set; }
string CalendarWeekColumnHeader { get; set; }

View File

@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Blocklisting;
using NzbDrone.Core.History;
@@ -23,10 +24,12 @@ namespace NzbDrone.Core.CustomFormats
public class CustomFormatCalculationService : ICustomFormatCalculationService
{
private readonly ICustomFormatService _formatService;
private readonly Logger _logger;
public CustomFormatCalculationService(ICustomFormatService formatService)
public CustomFormatCalculationService(ICustomFormatService formatService, Logger logger)
{
_formatService = formatService;
_logger = logger;
}
public List<CustomFormat> ParseCustomFormat(RemoteEpisode remoteEpisode, long size)
@@ -153,20 +156,23 @@ namespace NzbDrone.Core.CustomFormats
return matches.OrderBy(x => x.Name).ToList();
}
private static List<CustomFormat> ParseCustomFormat(EpisodeFile episodeFile, Series series, List<CustomFormat> allCustomFormats)
private List<CustomFormat> ParseCustomFormat(EpisodeFile episodeFile, Series series, List<CustomFormat> allCustomFormats)
{
var releaseTitle = string.Empty;
if (episodeFile.SceneName.IsNotNullOrWhiteSpace())
{
_logger.Trace("Using scene name for release title: {0}", episodeFile.SceneName);
releaseTitle = episodeFile.SceneName;
}
else if (episodeFile.OriginalFilePath.IsNotNullOrWhiteSpace())
{
_logger.Trace("Using original file path for release title: {0}", Path.GetFileName(episodeFile.OriginalFilePath));
releaseTitle = Path.GetFileName(episodeFile.OriginalFilePath);
}
else if (episodeFile.RelativePath.IsNotNullOrWhiteSpace())
{
_logger.Trace("Using relative path for release title: {0}", Path.GetFileName(episodeFile.RelativePath));
releaseTitle = Path.GetFileName(episodeFile.RelativePath);
}

View File

@@ -20,7 +20,7 @@ namespace NzbDrone.Core.CustomFormats
public abstract NzbDroneValidationResult Validate();
public bool IsSatisfiedBy(CustomFormatInput input)
public virtual bool IsSatisfiedBy(CustomFormatInput input)
{
var match = IsSatisfiedByWithoutNegate(input);

View File

@@ -27,9 +27,19 @@ namespace NzbDrone.Core.CustomFormats
public override int Order => 3;
public override string ImplementationName => "Language";
[FieldDefinition(1, Label = "Language", Type = FieldType.Select, SelectOptions = typeof(LanguageFieldConverter))]
[FieldDefinition(1, Label = "CustomFormatsSpecificationLanguage", Type = FieldType.Select, SelectOptions = typeof(LanguageFieldConverter))]
public int Value { get; set; }
public override bool IsSatisfiedBy(CustomFormatInput input)
{
if (Negate)
{
return IsSatisfiedByWithNegate(input);
}
return IsSatisfiedByWithoutNegate(input);
}
protected override bool IsSatisfiedByWithoutNegate(CustomFormatInput input)
{
var comparedLanguage = input.EpisodeInfo != null && input.Series != null && Value == Language.Original.Id && input.Series.OriginalLanguage != Language.Unknown
@@ -39,6 +49,15 @@ namespace NzbDrone.Core.CustomFormats
return input.Languages?.Contains(comparedLanguage) ?? false;
}
private bool IsSatisfiedByWithNegate(CustomFormatInput input)
{
var comparedLanguage = input.EpisodeInfo != null && input.Series != null && Value == Language.Original.Id && input.Series.OriginalLanguage != Language.Unknown
? input.Series.OriginalLanguage
: (Language)Value;
return !input.Languages?.Contains(comparedLanguage) ?? false;
}
public override NzbDroneValidationResult Validate()
{
return new NzbDroneValidationResult(Validator.Validate(this));

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