mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-04-17 21:26:13 -04:00
Compare commits
33 Commits
v4.0.6.182
...
v4.0.8.190
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80ca1a6ac2 | ||
|
|
f59c0b16ca | ||
|
|
0e95ba2021 | ||
|
|
c023fc7008 | ||
|
|
19466aa290 | ||
|
|
4b5ef4907b | ||
|
|
7b8d606a1b | ||
|
|
6a4824c029 | ||
|
|
1a1c8e6c08 | ||
|
|
e35b39b4b1 | ||
|
|
d3f14d5f5e | ||
|
|
06936c4f22 | ||
|
|
0a28ff84e8 | ||
|
|
703dee9383 | ||
|
|
dca5239420 | ||
|
|
1aaa9a14bc | ||
|
|
c6c37a408a | ||
|
|
ae4a97b4ae | ||
|
|
3afae968eb | ||
|
|
c01abbf3b5 | ||
|
|
f5ccf98162 | ||
|
|
6afd3bd344 | ||
|
|
acaf5cd353 | ||
|
|
e97e5bfe8f | ||
|
|
678872b879 | ||
|
|
10e9735c1c | ||
|
|
293a1bc618 | ||
|
|
0c883f7886 | ||
|
|
46c7de379c | ||
|
|
a83b521766 | ||
|
|
1d06e40acb | ||
|
|
bfcdc89f6a | ||
|
|
67943edfbc |
242
.github/workflows/build.yml
vendored
242
.github/workflows/build.yml
vendored
@@ -6,13 +6,13 @@ on:
|
||||
- develop
|
||||
- main
|
||||
paths-ignore:
|
||||
- 'src/Sonarr.Api.*/openapi.json'
|
||||
- "src/Sonarr.Api.*/openapi.json"
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
paths-ignore:
|
||||
- 'src/NzbDrone.Core/Localization/Core/**'
|
||||
- 'src/Sonarr.Api.*/openapi.json'
|
||||
- "src/NzbDrone.Core/Localization/Core/**"
|
||||
- "src/Sonarr.Api.*/openapi.json"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -22,7 +22,7 @@ env:
|
||||
FRAMEWORK: net6.0
|
||||
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
|
||||
SONARR_MAJOR_VERSION: 4
|
||||
VERSION: 4.0.6
|
||||
VERSION: 4.0.8
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
@@ -32,105 +32,105 @@ jobs:
|
||||
major_version: ${{ steps.variables.outputs.major_version }}
|
||||
version: ${{ steps.variables.outputs.version }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
||||
- name: Setup Environment Variables
|
||||
id: variables
|
||||
shell: bash
|
||||
run: |
|
||||
# Add 800 to the build number because GitHub won't let us pick an arbitrary starting point
|
||||
SONARR_VERSION="${{ env.VERSION }}.$((${{ github.run_number }}+800))"
|
||||
DOTNET_VERSION=$(jq -r '.sdk.version' global.json)
|
||||
- name: Setup Environment Variables
|
||||
id: variables
|
||||
shell: bash
|
||||
run: |
|
||||
# Add 800 to the build number because GitHub won't let us pick an arbitrary starting point
|
||||
SONARR_VERSION="${{ env.VERSION }}.$((${{ github.run_number }}+800))"
|
||||
DOTNET_VERSION=$(jq -r '.sdk.version' global.json)
|
||||
|
||||
echo "SDK_PATH=${{ env.DOTNET_ROOT }}/sdk/${DOTNET_VERSION}" >> "$GITHUB_ENV"
|
||||
echo "SONARR_VERSION=$SONARR_VERSION" >> "$GITHUB_ENV"
|
||||
echo "BRANCH=${RAW_BRANCH_NAME//\//-}" >> "$GITHUB_ENV"
|
||||
echo "SDK_PATH=${{ env.DOTNET_ROOT }}/sdk/${DOTNET_VERSION}" >> "$GITHUB_ENV"
|
||||
echo "SONARR_VERSION=$SONARR_VERSION" >> "$GITHUB_ENV"
|
||||
echo "BRANCH=${RAW_BRANCH_NAME//\//-}" >> "$GITHUB_ENV"
|
||||
|
||||
echo "framework=${{ env.FRAMEWORK }}" >> "$GITHUB_OUTPUT"
|
||||
echo "major_version=${{ env.SONARR_MAJOR_VERSION }}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$SONARR_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "framework=${{ env.FRAMEWORK }}" >> "$GITHUB_OUTPUT"
|
||||
echo "major_version=${{ env.SONARR_MAJOR_VERSION }}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$SONARR_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Enable Extra Platforms In SDK
|
||||
shell: bash
|
||||
run: ./build.sh --enable-extra-platforms-in-sdk
|
||||
- name: Enable Extra Platforms In SDK
|
||||
shell: bash
|
||||
run: ./build.sh --enable-extra-platforms-in-sdk
|
||||
|
||||
- name: Build Backend
|
||||
shell: bash
|
||||
run: ./build.sh --backend --enable-extra-platforms --packages
|
||||
- name: Build Backend
|
||||
shell: bash
|
||||
run: ./build.sh --backend --enable-extra-platforms --packages
|
||||
|
||||
# Test Artifacts
|
||||
# Test Artifacts
|
||||
|
||||
- name: Publish win-x64 Test Artifact
|
||||
uses: ./.github/actions/publish-test-artifact
|
||||
with:
|
||||
framework: ${{ env.FRAMEWORK }}
|
||||
runtime: win-x64
|
||||
- name: Publish win-x64 Test Artifact
|
||||
uses: ./.github/actions/publish-test-artifact
|
||||
with:
|
||||
framework: ${{ env.FRAMEWORK }}
|
||||
runtime: win-x64
|
||||
|
||||
- name: Publish linux-x64 Test Artifact
|
||||
uses: ./.github/actions/publish-test-artifact
|
||||
with:
|
||||
framework: ${{ env.FRAMEWORK }}
|
||||
runtime: linux-x64
|
||||
- name: Publish linux-x64 Test Artifact
|
||||
uses: ./.github/actions/publish-test-artifact
|
||||
with:
|
||||
framework: ${{ env.FRAMEWORK }}
|
||||
runtime: linux-x64
|
||||
|
||||
- name: Publish osx-arm64 Test Artifact
|
||||
uses: ./.github/actions/publish-test-artifact
|
||||
with:
|
||||
framework: ${{ env.FRAMEWORK }}
|
||||
runtime: osx-arm64
|
||||
- name: Publish osx-arm64 Test Artifact
|
||||
uses: ./.github/actions/publish-test-artifact
|
||||
with:
|
||||
framework: ${{ env.FRAMEWORK }}
|
||||
runtime: osx-arm64
|
||||
|
||||
# Build Artifacts (grouped by OS)
|
||||
|
||||
- name: Publish FreeBSD Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build_freebsd
|
||||
path: _artifacts/freebsd-*/**/*
|
||||
- name: Publish Linux Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build_linux
|
||||
path: _artifacts/linux-*/**/*
|
||||
- name: Publish macOS Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build_macos
|
||||
path: _artifacts/osx-*/**/*
|
||||
- name: Publish Windows Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build_windows
|
||||
path: _artifacts/win-*/**/*
|
||||
# Build Artifacts (grouped by OS)
|
||||
|
||||
- name: Publish FreeBSD Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build_freebsd
|
||||
path: _artifacts/freebsd-*/**/*
|
||||
- name: Publish Linux Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build_linux
|
||||
path: _artifacts/linux-*/**/*
|
||||
- name: Publish macOS Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build_macos
|
||||
path: _artifacts/osx-*/**/*
|
||||
- name: Publish Windows Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build_windows
|
||||
path: _artifacts/win-*/**/*
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Volta
|
||||
uses: volta-cli/action@v4
|
||||
- name: Volta
|
||||
uses: volta-cli/action@v4
|
||||
|
||||
- name: Yarn Install
|
||||
run: yarn install
|
||||
- name: Yarn Install
|
||||
run: yarn install
|
||||
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
|
||||
- name: Stylelint
|
||||
run: yarn stylelint -f github
|
||||
- name: Stylelint
|
||||
run: yarn stylelint -f github
|
||||
|
||||
- name: Build
|
||||
run: yarn build --env production
|
||||
- name: Build
|
||||
run: yarn build --env production
|
||||
|
||||
- name: Publish UI Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build_ui
|
||||
path: _output/UI/**/*
|
||||
- name: Publish UI Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build_ui
|
||||
path: _output/UI/**/*
|
||||
|
||||
unit_test:
|
||||
needs: backend
|
||||
@@ -150,32 +150,32 @@ jobs:
|
||||
filter: TestCategory!=ManualTest&TestCategory!=LINUX&TestCategory!=IntegrationTest&TestCategory!=AutomationTest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Test
|
||||
uses: ./.github/actions/test
|
||||
with:
|
||||
os: ${{ matrix.os }}
|
||||
artifact: ${{ matrix.artifact }}
|
||||
pattern: Sonarr.*.Test.dll
|
||||
filter: ${{ matrix.filter }}
|
||||
- name: Test
|
||||
uses: ./.github/actions/test
|
||||
with:
|
||||
os: ${{ matrix.os }}
|
||||
artifact: ${{ matrix.artifact }}
|
||||
pattern: Sonarr.*.Test.dll
|
||||
filter: ${{ matrix.filter }}
|
||||
|
||||
unit_test_postgres:
|
||||
needs: backend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Test
|
||||
uses: ./.github/actions/test
|
||||
with:
|
||||
os: ubuntu-latest
|
||||
artifact: tests-linux-x64
|
||||
pattern: Sonarr.*.Test.dll
|
||||
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory!=IntegrationTest&TestCategory!=AutomationTest
|
||||
use_postgres: true
|
||||
- name: Test
|
||||
uses: ./.github/actions/test
|
||||
with:
|
||||
os: ubuntu-latest
|
||||
artifact: tests-linux-x64
|
||||
pattern: Sonarr.*.Test.dll
|
||||
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory!=IntegrationTest&TestCategory!=AutomationTest
|
||||
use_postgres: true
|
||||
|
||||
integration_test:
|
||||
needs: backend
|
||||
@@ -201,19 +201,19 @@ jobs:
|
||||
binary_path: win-x64/${{ needs.backend.outputs.framework }}/Sonarr
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Test
|
||||
uses: ./.github/actions/test
|
||||
with:
|
||||
os: ${{ matrix.os }}
|
||||
artifact: ${{ matrix.artifact }}
|
||||
pattern: Sonarr.*.Test.dll
|
||||
filter: ${{ matrix.filter }}
|
||||
integration_tests: true
|
||||
binary_artifact: ${{ matrix.binary_artifact }}
|
||||
binary_path: ${{ matrix.binary_path }}
|
||||
- name: Test
|
||||
uses: ./.github/actions/test
|
||||
with:
|
||||
os: ${{ matrix.os }}
|
||||
artifact: ${{ matrix.artifact }}
|
||||
pattern: Sonarr.*.Test.dll
|
||||
filter: ${{ matrix.filter }}
|
||||
integration_tests: true
|
||||
binary_artifact: ${{ matrix.binary_artifact }}
|
||||
binary_path: ${{ matrix.binary_path }}
|
||||
|
||||
deploy:
|
||||
if: ${{ github.ref_name == 'develop' || github.ref_name == 'main' }}
|
||||
@@ -228,7 +228,15 @@ jobs:
|
||||
|
||||
notify:
|
||||
name: Discord Notification
|
||||
needs: [backend, frontend, unit_test, unit_test_postgres, integration_test, deploy]
|
||||
needs:
|
||||
[
|
||||
backend,
|
||||
frontend,
|
||||
unit_test,
|
||||
unit_test_postgres,
|
||||
integration_test,
|
||||
deploy,
|
||||
]
|
||||
if: ${{ !cancelled() && (github.ref_name == 'develop' || github.ref_name == 'main') }}
|
||||
env:
|
||||
STATUS: ${{ contains(needs.*.result, 'failure') && 'failure' || 'success' }}
|
||||
@@ -239,10 +247,10 @@ jobs:
|
||||
uses: tsickert/discord-webhook@v6.0.0
|
||||
with:
|
||||
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
username: 'GitHub Actions'
|
||||
avatar-url: 'https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png'
|
||||
username: "GitHub Actions"
|
||||
avatar-url: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png"
|
||||
embed-title: "${{ github.workflow }}: ${{ env.STATUS == 'success' && 'Success' || 'Failure' }}"
|
||||
embed-url: 'https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}'
|
||||
embed-url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
embed-description: |
|
||||
**Branch** ${{ github.ref }}
|
||||
**Build** ${{ needs.backend.outputs.version }}
|
||||
|
||||
@@ -118,6 +118,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
{
|
||||
key: 'blocklistAndSearch',
|
||||
value: translate('BlocklistAndSearch'),
|
||||
isDisabled: isPending,
|
||||
hint: multipleSelected
|
||||
? translate('BlocklistAndSearchMultipleHint')
|
||||
: translate('BlocklistAndSearchHint'),
|
||||
@@ -130,7 +131,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
: translate('BlocklistOnlyHint'),
|
||||
},
|
||||
];
|
||||
}, [multipleSelected]);
|
||||
}, [isPending, multipleSelected]);
|
||||
|
||||
const handleRemovalMethodChange = useCallback(
|
||||
({ value }: { value: RemovalMethod }) => {
|
||||
|
||||
@@ -145,6 +145,7 @@ class AddNewSeriesSearchResult extends Component {
|
||||
<Label size={sizes.LARGE}>
|
||||
<HeartRating
|
||||
rating={ratings.value}
|
||||
votes={ratings.votes}
|
||||
iconSize={13}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
@@ -30,7 +30,7 @@ import LogsTableConnector from 'System/Events/LogsTableConnector';
|
||||
import Logs from 'System/Logs/Logs';
|
||||
import Status from 'System/Status/Status';
|
||||
import Tasks from 'System/Tasks/Tasks';
|
||||
import UpdatesConnector from 'System/Updates/UpdatesConnector';
|
||||
import Updates from 'System/Updates/Updates';
|
||||
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
|
||||
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
|
||||
import MissingConnector from 'Wanted/Missing/MissingConnector';
|
||||
@@ -248,7 +248,7 @@ function AppRoutes(props) {
|
||||
|
||||
<Route
|
||||
path="/system/updates"
|
||||
component={UpdatesConnector}
|
||||
component={Updates}
|
||||
/>
|
||||
|
||||
<Route
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface CustomFilter {
|
||||
}
|
||||
|
||||
export interface AppSectionState {
|
||||
version: string;
|
||||
dimensions: {
|
||||
isSmallScreen: boolean;
|
||||
width: number;
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface SeriesIndexAppState {
|
||||
showTitle: boolean;
|
||||
showMonitored: boolean;
|
||||
showQualityProfile: boolean;
|
||||
showTags: boolean;
|
||||
showSearchAction: boolean;
|
||||
};
|
||||
|
||||
@@ -34,6 +35,7 @@ export interface SeriesIndexAppState {
|
||||
showSeasonCount: boolean;
|
||||
showPath: boolean;
|
||||
showSizeOnDisk: boolean;
|
||||
showTags: boolean;
|
||||
showSearchAction: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -14,13 +14,16 @@ import Indexer from 'typings/Indexer';
|
||||
import IndexerFlag from 'typings/IndexerFlag';
|
||||
import Notification from 'typings/Notification';
|
||||
import QualityProfile from 'typings/QualityProfile';
|
||||
import { UiSettings } from 'typings/UiSettings';
|
||||
import General from 'typings/Settings/General';
|
||||
import UiSettings from 'typings/Settings/UiSettings';
|
||||
|
||||
export interface DownloadClientAppState
|
||||
extends AppSectionState<DownloadClient>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export type GeneralAppState = AppSectionItemState<General>;
|
||||
|
||||
export interface ImportListAppState
|
||||
extends AppSectionState<ImportList>,
|
||||
AppSectionDeleteState,
|
||||
@@ -58,6 +61,7 @@ export type UiSettingsAppState = AppSectionItemState<UiSettings>;
|
||||
interface SettingsAppState {
|
||||
advancedSettings: boolean;
|
||||
downloadClients: DownloadClientAppState;
|
||||
general: GeneralAppState;
|
||||
importListExclusions: ImportListExclusionsSettingsAppState;
|
||||
importListOptions: ImportListOptionsSettingsAppState;
|
||||
importLists: ImportListAppState;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import SystemStatus from 'typings/SystemStatus';
|
||||
import { AppSectionItemState } from './AppSectionState';
|
||||
import Update from 'typings/Update';
|
||||
import AppSectionState, { AppSectionItemState } from './AppSectionState';
|
||||
|
||||
export type SystemStatusAppState = AppSectionItemState<SystemStatus>;
|
||||
export type UpdateAppState = AppSectionState<Update>;
|
||||
|
||||
interface SystemAppState {
|
||||
updates: UpdateAppState;
|
||||
status: SystemStatusAppState;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { Component } from 'react';
|
||||
import SelectInput from 'Components/Form/SelectInput';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue';
|
||||
import DateFilterBuilderRowValue from './DateFilterBuilderRowValue';
|
||||
import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector';
|
||||
@@ -224,7 +225,7 @@ class FilterBuilderRow extends Component {
|
||||
key: name,
|
||||
value: typeof label === 'function' ? label() : label
|
||||
};
|
||||
}).sort((a, b) => a.value.localeCompare(b.value));
|
||||
}).sort(sortByProp('value'));
|
||||
|
||||
const ValueComponent = getRowValueConnector(selectedFilterBuilderProp);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { filterBuilderTypes } from 'Helpers/Props';
|
||||
import * as filterTypes from 'Helpers/Props/filterTypes';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import FilterBuilderRowValue from './FilterBuilderRowValue';
|
||||
|
||||
function createTagListSelector() {
|
||||
@@ -38,7 +38,7 @@ function createTagListSelector() {
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []).sort(sortByName);
|
||||
}, []).sort(sortByProp('name'));
|
||||
}
|
||||
|
||||
return _.uniqBy(items, 'id');
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Series from 'Series/Series';
|
||||
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import FilterBuilderRowValue from './FilterBuilderRowValue';
|
||||
import FilterBuilderRowValueProps from './FilterBuilderRowValueProps';
|
||||
|
||||
@@ -11,7 +11,7 @@ function SeriesFilterBuilderRowValue(props: FilterBuilderRowValueProps) {
|
||||
|
||||
const tagList = allSeries
|
||||
.map((series) => ({ id: series.id, name: series.title }))
|
||||
.sort(sortByName);
|
||||
.sort(sortByProp('name'));
|
||||
|
||||
return <FilterBuilderRowValue {...props} tagList={tagList} />;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 sortByProp from 'Utilities/Array/sortByProp';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import CustomFilter from './CustomFilter';
|
||||
import styles from './CustomFiltersModalContent.css';
|
||||
@@ -31,7 +32,7 @@ function CustomFiltersModalContent(props) {
|
||||
<ModalBody>
|
||||
{
|
||||
customFilters
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
.sort((a, b) => sortByProp(a, b, 'label'))
|
||||
.map((customFilter) => {
|
||||
return (
|
||||
<CustomFilter
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchDownloadClients } from 'Store/Actions/settingsActions';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
|
||||
@@ -23,7 +23,7 @@ function createMapStateToProps() {
|
||||
|
||||
const filteredItems = items.filter((item) => item.protocol === protocolFilter);
|
||||
|
||||
const values = _.map(filteredItems.sort(sortByName), (downloadClient) => {
|
||||
const values = _.map(filteredItems.sort(sortByProp('name')), (downloadClient) => {
|
||||
return {
|
||||
key: downloadClient.id,
|
||||
value: downloadClient.name,
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchIndexers } from 'Store/Actions/settingsActions';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
|
||||
@@ -20,7 +20,7 @@ function createMapStateToProps() {
|
||||
items
|
||||
} = indexers;
|
||||
|
||||
const values = _.map(items.sort(sortByName), (indexer) => {
|
||||
const values = _.map(items.sort(sortByProp('name')), (indexer) => {
|
||||
return {
|
||||
key: indexer.id,
|
||||
value: indexer.name
|
||||
|
||||
@@ -4,13 +4,13 @@ import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createSortedSectionSelector('settings.qualityProfiles', sortByName),
|
||||
createSortedSectionSelector('settings.qualityProfiles', sortByProp('name')),
|
||||
(state, { includeNoChange }) => includeNoChange,
|
||||
(state, { includeNoChangeDisabled }) => includeNoChangeDisabled,
|
||||
(state, { includeMixed }) => includeMixed,
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import Tooltip from 'Components/Tooltip/Tooltip';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './HeartRating.css';
|
||||
|
||||
function HeartRating({ rating, iconSize }) {
|
||||
function HeartRating({ rating, votes, iconSize }) {
|
||||
return (
|
||||
<span className={styles.rating}>
|
||||
<Icon
|
||||
className={styles.heart}
|
||||
name={icons.HEART}
|
||||
size={iconSize}
|
||||
/>
|
||||
<Tooltip
|
||||
anchor={
|
||||
<span className={styles.rating}>
|
||||
<Icon
|
||||
className={styles.heart}
|
||||
name={icons.HEART}
|
||||
size={iconSize}
|
||||
/>
|
||||
|
||||
{rating * 10}%
|
||||
</span>
|
||||
{rating * 10}%
|
||||
</span>
|
||||
}
|
||||
tooltip={translate('CountVotes', { votes })}
|
||||
kind={kinds.INVERSE}
|
||||
position={tooltipPositions.TOP}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
HeartRating.propTypes = {
|
||||
rating: PropTypes.number.isRequired,
|
||||
votes: PropTypes.number.isRequired,
|
||||
iconSize: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
HeartRating.defaultProps = {
|
||||
votes: 0,
|
||||
iconSize: 14
|
||||
};
|
||||
|
||||
|
||||
@@ -88,6 +88,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.purple {
|
||||
border-color: var(--purple);
|
||||
background-color: var(--purple);
|
||||
|
||||
&.outline {
|
||||
color: var(--purple);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sizes **/
|
||||
|
||||
.small {
|
||||
|
||||
1
frontend/src/Components/Label.css.d.ts
vendored
1
frontend/src/Components/Label.css.d.ts
vendored
@@ -11,6 +11,7 @@ interface CssExports {
|
||||
'medium': string;
|
||||
'outline': string;
|
||||
'primary': string;
|
||||
'purple': string;
|
||||
'small': string;
|
||||
'success': string;
|
||||
'warning': string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import FilterMenuItem from './FilterMenuItem';
|
||||
import MenuContent from './MenuContent';
|
||||
@@ -47,7 +48,7 @@ class FilterMenuContent extends Component {
|
||||
|
||||
{
|
||||
customFilters
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
.sort(sortByProp('label'))
|
||||
.map((filter) => {
|
||||
return (
|
||||
<FilterMenuItem
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import Label from './Label';
|
||||
import styles from './TagList.css';
|
||||
|
||||
@@ -8,7 +9,7 @@ function TagList({ tags, tagList }) {
|
||||
const sortedTags = tags
|
||||
.map((tagId) => tagList.find((tag) => tag.id === tagId))
|
||||
.filter((tag) => !!tag)
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
.sort(sortByProp('label'));
|
||||
|
||||
return (
|
||||
<div className={styles.tags}>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { scrollDirections } from 'Helpers/Props';
|
||||
import Series from 'Series/Series';
|
||||
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
||||
import dimensions from 'Styles/Variables/dimensions';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import SelectSeriesModalTableHeader from './SelectSeriesModalTableHeader';
|
||||
import SelectSeriesRow from './SelectSeriesRow';
|
||||
@@ -163,9 +164,7 @@ function SelectSeriesModalContent(props: SelectSeriesModalContentProps) {
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const sorted = [...allSeries].sort((a, b) =>
|
||||
a.sortTitle.localeCompare(b.sortTitle)
|
||||
);
|
||||
const sorted = [...allSeries].sort(sortByProp('sortTitle'));
|
||||
|
||||
return sorted.filter(
|
||||
(item) =>
|
||||
|
||||
69
frontend/src/Series/Details/SeasonProgressLabel.tsx
Normal file
69
frontend/src/Series/Details/SeasonProgressLabel.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import createSeriesQueueItemsDetailsSelector, {
|
||||
SeriesQueueDetails,
|
||||
} from 'Series/Index/createSeriesQueueDetailsSelector';
|
||||
|
||||
function getEpisodeCountKind(
|
||||
monitored: boolean,
|
||||
episodeFileCount: number,
|
||||
episodeCount: number,
|
||||
isDownloading: boolean
|
||||
) {
|
||||
if (isDownloading) {
|
||||
return kinds.PURPLE;
|
||||
}
|
||||
|
||||
if (episodeFileCount === episodeCount && episodeCount > 0) {
|
||||
return kinds.SUCCESS;
|
||||
}
|
||||
|
||||
if (!monitored) {
|
||||
return kinds.WARNING;
|
||||
}
|
||||
|
||||
return kinds.DANGER;
|
||||
}
|
||||
|
||||
interface SeasonProgressLabelProps {
|
||||
seriesId: number;
|
||||
seasonNumber: number;
|
||||
monitored: boolean;
|
||||
episodeCount: number;
|
||||
episodeFileCount: number;
|
||||
}
|
||||
|
||||
function SeasonProgressLabel({
|
||||
seriesId,
|
||||
seasonNumber,
|
||||
monitored,
|
||||
episodeCount,
|
||||
episodeFileCount,
|
||||
}: SeasonProgressLabelProps) {
|
||||
const queueDetails: SeriesQueueDetails = useSelector(
|
||||
createSeriesQueueItemsDetailsSelector(seriesId, seasonNumber)
|
||||
);
|
||||
|
||||
const newDownloads = queueDetails.count - queueDetails.episodesWithFiles;
|
||||
const text = newDownloads
|
||||
? `${episodeFileCount} + ${newDownloads} / ${episodeCount}`
|
||||
: `${episodeFileCount} / ${episodeCount}`;
|
||||
|
||||
return (
|
||||
<Label
|
||||
kind={getEpisodeCountKind(
|
||||
monitored,
|
||||
episodeFileCount,
|
||||
episodeCount,
|
||||
queueDetails.count > 0
|
||||
)}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<span>{text}</span>
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeasonProgressLabel;
|
||||
@@ -412,10 +412,12 @@ class SeriesDetails extends Component {
|
||||
ratings.value ?
|
||||
<HeartRating
|
||||
rating={ratings.value}
|
||||
votes={ratings.votes}
|
||||
iconSize={20}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
<SeriesGenres genres={genres} />
|
||||
|
||||
<span>
|
||||
|
||||
@@ -2,7 +2,6 @@ import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Label from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import Link from 'Components/Link/Link';
|
||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||
@@ -15,7 +14,7 @@ import SpinnerIcon from 'Components/SpinnerIcon';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { align, icons, kinds, sizes, sortDirections, tooltipPositions } from 'Helpers/Props';
|
||||
import { align, icons, sortDirections, tooltipPositions } from 'Helpers/Props';
|
||||
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
|
||||
import SeriesHistoryModal from 'Series/History/SeriesHistoryModal';
|
||||
@@ -27,6 +26,7 @@ import translate from 'Utilities/String/translate';
|
||||
import getToggledRange from 'Utilities/Table/getToggledRange';
|
||||
import EpisodeRowConnector from './EpisodeRowConnector';
|
||||
import SeasonInfo from './SeasonInfo';
|
||||
import SeasonProgressLabel from './SeasonProgressLabel';
|
||||
import styles from './SeriesDetailsSeason.css';
|
||||
|
||||
function getSeasonStatistics(episodes) {
|
||||
@@ -64,18 +64,6 @@ function getSeasonStatistics(episodes) {
|
||||
};
|
||||
}
|
||||
|
||||
function getEpisodeCountKind(monitored, episodeFileCount, episodeCount) {
|
||||
if (episodeFileCount === episodeCount && episodeCount > 0) {
|
||||
return kinds.SUCCESS;
|
||||
}
|
||||
|
||||
if (!monitored) {
|
||||
return kinds.WARNING;
|
||||
}
|
||||
|
||||
return kinds.DANGER;
|
||||
}
|
||||
|
||||
class SeriesDetailsSeason extends Component {
|
||||
|
||||
//
|
||||
@@ -265,12 +253,13 @@ class SeriesDetailsSeason extends Component {
|
||||
className={styles.episodeCountTooltip}
|
||||
canFlip={true}
|
||||
anchor={
|
||||
<Label
|
||||
kind={getEpisodeCountKind(monitored, episodeFileCount, episodeCount)}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<span>{episodeFileCount} / {episodeCount}</span>
|
||||
</Label>
|
||||
<SeasonProgressLabel
|
||||
seriesId={seriesId}
|
||||
seasonNumber={seasonNumber}
|
||||
monitored={monitored}
|
||||
episodeCount={episodeCount}
|
||||
episodeFileCount={episodeFileCount}
|
||||
/>
|
||||
}
|
||||
title={translate('SeasonInformation')}
|
||||
body={
|
||||
|
||||
@@ -2,6 +2,7 @@ import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import SeriesTags from './SeriesTags';
|
||||
|
||||
function createMapStateToProps() {
|
||||
@@ -12,8 +13,8 @@ function createMapStateToProps() {
|
||||
const tags = series.tags
|
||||
.map((tagId) => tagList.find((tag) => tag.id === tagId))
|
||||
.filter((tag) => !!tag)
|
||||
.map((tag) => tag.label)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
.sort(sortByProp('label'))
|
||||
.map((tag) => tag.label);
|
||||
|
||||
return {
|
||||
tags
|
||||
|
||||
@@ -55,6 +55,7 @@ function SeriesIndexOverviewOptionsModalContent(
|
||||
showSeasonCount,
|
||||
showPath,
|
||||
showSizeOnDisk,
|
||||
showTags,
|
||||
showSearchAction,
|
||||
} = useSelector(selectOverviewOptions);
|
||||
|
||||
@@ -185,6 +186,17 @@ function SeriesIndexOverviewOptionsModalContent(
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ShowTags')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="showTags"
|
||||
value={showTags}
|
||||
onChange={onOverviewOptionChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ShowSearch')}</FormLabel>
|
||||
|
||||
|
||||
@@ -73,14 +73,26 @@ $hoverScale: 1.05;
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.overviewContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex: 0 1 1000px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.overview {
|
||||
composes: link;
|
||||
|
||||
flex: 0 1 1000px;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.overview {
|
||||
display: none;
|
||||
|
||||
@@ -8,8 +8,10 @@ interface CssExports {
|
||||
'info': string;
|
||||
'link': string;
|
||||
'overview': string;
|
||||
'overviewContainer': string;
|
||||
'poster': string;
|
||||
'posterContainer': string;
|
||||
'tags': string;
|
||||
'title': string;
|
||||
'titleRow': string;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { REFRESH_SERIES, SERIES_SEARCH } from 'Commands/commandNames';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import Link from 'Components/Link/Link';
|
||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||
import TagListConnector from 'Components/TagListConnector';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
|
||||
import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
|
||||
@@ -70,6 +71,7 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) {
|
||||
overview,
|
||||
statistics = {} as Statistics,
|
||||
images,
|
||||
tags,
|
||||
network,
|
||||
} = series;
|
||||
|
||||
@@ -205,15 +207,22 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) {
|
||||
</div>
|
||||
|
||||
<div className={styles.details}>
|
||||
<Link className={styles.overview} to={link}>
|
||||
<TextTruncate
|
||||
line={Math.floor(
|
||||
overviewHeight / (defaultFontSize * lineHeight)
|
||||
)}
|
||||
text={overview}
|
||||
/>
|
||||
</Link>
|
||||
<div className={styles.overviewContainer}>
|
||||
<Link className={styles.overview} to={link}>
|
||||
<TextTruncate
|
||||
line={Math.floor(
|
||||
overviewHeight / (defaultFontSize * lineHeight)
|
||||
)}
|
||||
text={overview}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{overviewOptions.showTags ? (
|
||||
<div className={styles.tags}>
|
||||
<TagListConnector tags={tags} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<SeriesIndexOverviewInfo
|
||||
height={overviewHeight}
|
||||
monitored={monitored}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { icons } from 'Helpers/Props';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import dimensions from 'Styles/Variables/dimensions';
|
||||
import QualityProfile from 'typings/QualityProfile';
|
||||
import { UiSettings } from 'typings/UiSettings';
|
||||
import UiSettings from 'typings/Settings/UiSettings';
|
||||
import formatDateTime from 'Utilities/Date/formatDateTime';
|
||||
import getRelativeDate from 'Utilities/Date/getRelativeDate';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
|
||||
@@ -52,6 +52,7 @@ function SeriesIndexPosterOptionsModalContent(
|
||||
showTitle,
|
||||
showMonitored,
|
||||
showQualityProfile,
|
||||
showTags,
|
||||
showSearchAction,
|
||||
} = posterOptions;
|
||||
|
||||
@@ -130,6 +131,18 @@ function SeriesIndexPosterOptionsModalContent(
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ShowTags')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="showTags"
|
||||
value={showTags}
|
||||
helpText={translate('ShowTagsHelpText')}
|
||||
onChange={onPosterOptionChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ShowSearch')}</FormLabel>
|
||||
|
||||
|
||||
@@ -57,6 +57,20 @@ $hoverScale: 1.05;
|
||||
font-size: $smallFontSize;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding: 0 3px;
|
||||
height: 21px;
|
||||
background-color: var(--seriesBackgroundColor);
|
||||
}
|
||||
|
||||
.tagsList {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ended {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -10,6 +10,8 @@ interface CssExports {
|
||||
'nextAiring': string;
|
||||
'overlayTitle': string;
|
||||
'posterContainer': string;
|
||||
'tags': string;
|
||||
'tagsList': string;
|
||||
'title': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
|
||||
@@ -5,6 +5,7 @@ import Label from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import Link from 'Components/Link/Link';
|
||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||
import TagListConnector from 'Components/TagListConnector';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal';
|
||||
import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector';
|
||||
@@ -41,6 +42,7 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
|
||||
showTitle,
|
||||
showMonitored,
|
||||
showQualityProfile,
|
||||
showTags,
|
||||
showSearchAction,
|
||||
} = useSelector(selectPosterOptions);
|
||||
|
||||
@@ -60,6 +62,7 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
|
||||
added,
|
||||
statistics = {} as Statistics,
|
||||
images,
|
||||
tags,
|
||||
} = series;
|
||||
|
||||
const {
|
||||
@@ -208,6 +211,14 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showTags && tags.length ? (
|
||||
<div className={styles.tags}>
|
||||
<div className={styles.tagsList}>
|
||||
<TagListConnector tags={tags} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{nextAiring ? (
|
||||
<div
|
||||
className={styles.nextAiring}
|
||||
|
||||
@@ -141,6 +141,7 @@ export default function SeriesIndexPosters(props: SeriesIndexPostersProps) {
|
||||
showTitle,
|
||||
showMonitored,
|
||||
showQualityProfile,
|
||||
showTags,
|
||||
} = posterOptions;
|
||||
|
||||
const nextAiringHeight = 19;
|
||||
@@ -164,6 +165,10 @@ export default function SeriesIndexPosters(props: SeriesIndexPostersProps) {
|
||||
heights.push(19);
|
||||
}
|
||||
|
||||
if (showTags) {
|
||||
heights.push(21);
|
||||
}
|
||||
|
||||
switch (sortKey) {
|
||||
case 'network':
|
||||
case 'seasons':
|
||||
|
||||
@@ -401,7 +401,7 @@ function SeriesIndexRow(props: SeriesIndexRowProps) {
|
||||
if (name === 'ratings') {
|
||||
return (
|
||||
<VirtualTableRowCell key={name} className={styles[name]}>
|
||||
<HeartRating rating={ratings.value} />
|
||||
<HeartRating rating={ratings.value} votes={ratings.votes} />
|
||||
</VirtualTableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@ import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { cloneCustomFormat, deleteCustomFormat, fetchCustomFormats } from 'Store/Actions/settingsActions';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import CustomFormats from './CustomFormats';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createSortedSectionSelector('settings.customFormats', sortByName),
|
||||
createSortedSectionSelector('settings.customFormats', sortByProp('name')),
|
||||
(customFormats) => customFormats
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,3 +25,8 @@
|
||||
border-radius: 4px;
|
||||
background-color: var(--cardCenterBackgroundColor);
|
||||
}
|
||||
|
||||
.customFormats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
interface CssExports {
|
||||
'addSpecification': string;
|
||||
'center': string;
|
||||
'customFormats': string;
|
||||
'deleteButton': string;
|
||||
'rightButtons': string;
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ import { createSelector } from 'reselect';
|
||||
import { deleteDownloadClient, fetchDownloadClients } from 'Store/Actions/settingsActions';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import DownloadClients from './DownloadClients';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createSortedSectionSelector('settings.downloadClients', sortByName),
|
||||
createSortedSectionSelector('settings.downloadClients', sortByProp('name')),
|
||||
createTagsSelector(),
|
||||
(downloadClients, tagList) => {
|
||||
return {
|
||||
|
||||
@@ -17,7 +17,6 @@ function UpdateSettings(props) {
|
||||
const {
|
||||
advancedSettings,
|
||||
settings,
|
||||
isWindows,
|
||||
packageUpdateMechanism,
|
||||
onInputChange
|
||||
} = props;
|
||||
@@ -68,63 +67,59 @@ function UpdateSettings(props) {
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
isWindows ?
|
||||
null :
|
||||
<div>
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
size={sizes.MEDIUM}
|
||||
>
|
||||
<FormLabel>{translate('Automatic')}</FormLabel>
|
||||
<div>
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
size={sizes.MEDIUM}
|
||||
>
|
||||
<FormLabel>{translate('Automatic')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="updateAutomatically"
|
||||
helpText={translate('UpdateAutomaticallyHelpText')}
|
||||
helpTextWarning={updateMechanism.value === 'docker' ? translate('AutomaticUpdatesDisabledDocker') : undefined}
|
||||
onChange={onInputChange}
|
||||
{...updateAutomatically}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="updateAutomatically"
|
||||
helpText={translate('UpdateAutomaticallyHelpText')}
|
||||
helpTextWarning={updateMechanism.value === 'docker' ? translate('AutomaticUpdatesDisabledDocker') : undefined}
|
||||
onChange={onInputChange}
|
||||
{...updateAutomatically}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>{translate('Mechanism')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="updateMechanism"
|
||||
values={updateOptions}
|
||||
helpText={translate('UpdateMechanismHelpText')}
|
||||
helpLink="https://wiki.servarr.com/sonarr/settings#updates"
|
||||
onChange={onInputChange}
|
||||
{...updateMechanism}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
updateMechanism.value === 'script' &&
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>{translate('Mechanism')}</FormLabel>
|
||||
<FormLabel>{translate('ScriptPath')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="updateMechanism"
|
||||
values={updateOptions}
|
||||
helpText={translate('UpdateMechanismHelpText')}
|
||||
helpLink="https://wiki.servarr.com/sonarr/settings#updates"
|
||||
type={inputTypes.TEXT}
|
||||
name="updateScriptPath"
|
||||
helpText={translate('UpdateScriptPathHelpText')}
|
||||
onChange={onInputChange}
|
||||
{...updateMechanism}
|
||||
{...updateScriptPath}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
updateMechanism.value === 'script' &&
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>{translate('ScriptPath')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="updateScriptPath"
|
||||
helpText={translate('UpdateScriptPathHelpText')}
|
||||
onChange={onInputChange}
|
||||
{...updateScriptPath}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ import { createSelector } from 'reselect';
|
||||
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||
import { deleteImportList, fetchImportLists } from 'Store/Actions/settingsActions';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import ImportLists from './ImportLists';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createSortedSectionSelector('settings.importLists', sortByName),
|
||||
createSortedSectionSelector('settings.importLists', sortByProp('name')),
|
||||
(importLists) => importLists
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ import { createSelector } from 'reselect';
|
||||
import { cloneIndexer, deleteIndexer, fetchIndexers } from 'Store/Actions/settingsActions';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import Indexers from './Indexers';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createSortedSectionSelector('settings.indexers', sortByName),
|
||||
createSortedSectionSelector('settings.indexers', sortByProp('name')),
|
||||
createTagsSelector(),
|
||||
(indexers, tagList) => {
|
||||
return {
|
||||
|
||||
@@ -138,7 +138,8 @@ class Naming extends Component {
|
||||
{ key: 1, value: translate('ReplaceWithDash') },
|
||||
{ key: 2, value: translate('ReplaceWithSpaceDash') },
|
||||
{ key: 3, value: translate('ReplaceWithSpaceDashSpace') },
|
||||
{ key: 4, value: translate('SmartReplace'), hint: translate('SmartReplaceHint') }
|
||||
{ key: 4, value: translate('SmartReplace'), hint: translate('SmartReplaceHint') },
|
||||
{ key: 5, value: translate('Custom'), hint: translate('CustomColonReplacementFormatHint') }
|
||||
];
|
||||
|
||||
const standardEpisodeFormatHelpTexts = [];
|
||||
@@ -262,6 +263,22 @@ class Naming extends Component {
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
replaceIllegalCharacters && settings.colonReplacementFormat.value === 5 ?
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ColonReplacement')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="customColonReplacementFormat"
|
||||
helpText={translate('CustomColonReplacementFormatHelpText')}
|
||||
onChange={onInputChange}
|
||||
{...settings.customColonReplacementFormat}
|
||||
/>
|
||||
</FormGroup> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
renameEpisodes &&
|
||||
<div>
|
||||
|
||||
@@ -4,12 +4,12 @@ import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchMetadata } from 'Store/Actions/settingsActions';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import Metadatas from './Metadatas';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createSortedSectionSelector('settings.metadata', sortByName),
|
||||
createSortedSectionSelector('settings.metadata', sortByProp('name')),
|
||||
(metadata) => metadata
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ class Notification extends Component {
|
||||
onGrab,
|
||||
onDownload,
|
||||
onUpgrade,
|
||||
onImportComplete,
|
||||
onRename,
|
||||
onSeriesAdd,
|
||||
onSeriesDelete,
|
||||
@@ -71,6 +72,7 @@ class Notification extends Component {
|
||||
supportsOnGrab,
|
||||
supportsOnDownload,
|
||||
supportsOnUpgrade,
|
||||
supportsOnImportComplete,
|
||||
supportsOnRename,
|
||||
supportsOnSeriesAdd,
|
||||
supportsOnSeriesDelete,
|
||||
@@ -105,7 +107,7 @@ class Notification extends Component {
|
||||
{
|
||||
supportsOnDownload && onDownload ?
|
||||
<Label kind={kinds.SUCCESS}>
|
||||
{translate('OnImport')}
|
||||
{translate('OnFileImport')}
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
@@ -113,7 +115,15 @@ class Notification extends Component {
|
||||
{
|
||||
supportsOnUpgrade && onDownload && onUpgrade ?
|
||||
<Label kind={kinds.SUCCESS}>
|
||||
{translate('OnUpgrade')}
|
||||
{translate('OnFileUpgrade')}
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
supportsOnImportComplete && onImportComplete ?
|
||||
<Label kind={kinds.SUCCESS}>
|
||||
{translate('OnImportComplete')}
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
@@ -191,7 +201,7 @@ class Notification extends Component {
|
||||
}
|
||||
|
||||
{
|
||||
!onGrab && !onDownload && !onRename && !onHealthIssue && !onHealthRestored && !onApplicationUpdate && !onSeriesAdd && !onSeriesDelete && !onEpisodeFileDelete && !onManualInteractionRequired ?
|
||||
!onGrab && !onDownload && !onRename && !onImportComplete && !onHealthIssue && !onHealthRestored && !onApplicationUpdate && !onSeriesAdd && !onSeriesDelete && !onEpisodeFileDelete && !onManualInteractionRequired ?
|
||||
<Label
|
||||
kind={kinds.DISABLED}
|
||||
outline={true}
|
||||
@@ -233,6 +243,7 @@ Notification.propTypes = {
|
||||
onGrab: PropTypes.bool.isRequired,
|
||||
onDownload: PropTypes.bool.isRequired,
|
||||
onUpgrade: PropTypes.bool.isRequired,
|
||||
onImportComplete: PropTypes.bool.isRequired,
|
||||
onRename: PropTypes.bool.isRequired,
|
||||
onSeriesAdd: PropTypes.bool.isRequired,
|
||||
onSeriesDelete: PropTypes.bool.isRequired,
|
||||
@@ -244,6 +255,7 @@ Notification.propTypes = {
|
||||
onManualInteractionRequired: PropTypes.bool.isRequired,
|
||||
supportsOnGrab: PropTypes.bool.isRequired,
|
||||
supportsOnDownload: PropTypes.bool.isRequired,
|
||||
supportsOnImportComplete: PropTypes.bool.isRequired,
|
||||
supportsOnSeriesAdd: PropTypes.bool.isRequired,
|
||||
supportsOnSeriesDelete: PropTypes.bool.isRequired,
|
||||
supportsOnEpisodeFileDelete: PropTypes.bool.isRequired,
|
||||
|
||||
@@ -18,6 +18,7 @@ function NotificationEventItems(props) {
|
||||
onGrab,
|
||||
onDownload,
|
||||
onUpgrade,
|
||||
onImportComplete,
|
||||
onRename,
|
||||
onSeriesAdd,
|
||||
onSeriesDelete,
|
||||
@@ -30,6 +31,7 @@ function NotificationEventItems(props) {
|
||||
supportsOnGrab,
|
||||
supportsOnDownload,
|
||||
supportsOnUpgrade,
|
||||
supportsOnImportComplete,
|
||||
supportsOnRename,
|
||||
supportsOnSeriesAdd,
|
||||
supportsOnSeriesDelete,
|
||||
@@ -66,7 +68,7 @@ function NotificationEventItems(props) {
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="onDownload"
|
||||
helpText={translate('OnImport')}
|
||||
helpText={translate('OnFileImport')}
|
||||
isDisabled={!supportsOnDownload.value}
|
||||
{...onDownload}
|
||||
onChange={onInputChange}
|
||||
@@ -79,7 +81,7 @@ function NotificationEventItems(props) {
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="onUpgrade"
|
||||
helpText={translate('OnUpgrade')}
|
||||
helpText={translate('OnFileUpgrade')}
|
||||
isDisabled={!supportsOnUpgrade.value}
|
||||
{...onUpgrade}
|
||||
onChange={onInputChange}
|
||||
@@ -87,6 +89,17 @@ function NotificationEventItems(props) {
|
||||
</div>
|
||||
}
|
||||
|
||||
<div>
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="onImportComplete"
|
||||
helpText={translate('OnImportComplete')}
|
||||
isDisabled={!supportsOnImportComplete.value}
|
||||
{...onImportComplete}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
|
||||
@@ -5,12 +5,12 @@ import { createSelector } from 'reselect';
|
||||
import { deleteNotification, fetchNotifications } from 'Store/Actions/settingsActions';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import Notifications from './Notifications';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createSortedSectionSelector('settings.notifications', sortByName),
|
||||
createSortedSectionSelector('settings.notifications', sortByProp('name')),
|
||||
createTagsSelector(),
|
||||
(notifications, tagList) => {
|
||||
return {
|
||||
|
||||
@@ -20,7 +20,8 @@ function calcOrder(profileFormatItems) {
|
||||
if (b.score !== a.score) {
|
||||
return b.score - a.score;
|
||||
}
|
||||
return a.name > b.name ? 1 : -1;
|
||||
|
||||
return a.name.localeCompare(b.name, undefined, { numeric: true });
|
||||
}).map((x) => items[x.format]);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@ import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { cloneQualityProfile, deleteQualityProfile, fetchQualityProfiles } from 'Store/Actions/settingsActions';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import QualityProfiles from './QualityProfiles';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createSortedSectionSelector('settings.qualityProfiles', sortByName),
|
||||
createSortedSectionSelector('settings.qualityProfiles', sortByProp('name')),
|
||||
(qualityProfiles) => qualityProfiles
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||
import { cloneAutoTagging, deleteAutoTagging, fetchAutoTaggings } from 'Store/Actions/settingsActions';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import AutoTagging from './AutoTagging';
|
||||
import EditAutoTaggingModal from './EditAutoTaggingModal';
|
||||
@@ -23,7 +23,7 @@ export default function AutoTaggings() {
|
||||
isFetching,
|
||||
isPopulated
|
||||
} = useSelector(
|
||||
createSortedSectionSelector('settings.autoTaggings', sortByName)
|
||||
createSortedSectionSelector('settings.autoTaggings', sortByProp('name'))
|
||||
);
|
||||
|
||||
const tagList = useSelector(createTagsSelector());
|
||||
|
||||
@@ -25,3 +25,8 @@
|
||||
border-radius: 4px;
|
||||
background-color: var(--cardCenterBackgroundColor);
|
||||
}
|
||||
|
||||
.autoTaggings {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'addSpecification': string;
|
||||
'autoTaggings': string;
|
||||
'center': string;
|
||||
'deleteButton': string;
|
||||
'rightButtons': string;
|
||||
|
||||
@@ -116,6 +116,7 @@ export default {
|
||||
selectedSchema.onGrab = selectedSchema.supportsOnGrab;
|
||||
selectedSchema.onDownload = selectedSchema.supportsOnDownload;
|
||||
selectedSchema.onUpgrade = selectedSchema.supportsOnUpgrade;
|
||||
selectedSchema.onImportComplete = selectedSchema.supportsOnImportComplete;
|
||||
selectedSchema.onRename = selectedSchema.supportsOnRename;
|
||||
selectedSchema.onSeriesAdd = selectedSchema.supportsOnSeriesAdd;
|
||||
selectedSchema.onSeriesDelete = selectedSchema.supportsOnSeriesDelete;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createAction } from 'redux-actions';
|
||||
import { filterBuilderTypes, filterBuilderValueTypes, filterTypePredicates, filterTypes, sortDirections } from 'Helpers/Props';
|
||||
import { createThunk, handleThunks } from 'Store/thunks';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import createFetchHandler from './Creators/createFetchHandler';
|
||||
@@ -232,7 +232,7 @@ export const defaultState = {
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return genreList.sort(sortByName);
|
||||
return genreList.sort(sortByProp('name'));
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createAction } from 'redux-actions';
|
||||
import { batchActions } from 'redux-batched-actions';
|
||||
import { filterBuilderTypes, filterBuilderValueTypes, filterTypePredicates, filterTypes, sortDirections } from 'Helpers/Props';
|
||||
import { createThunk, handleThunks } from 'Store/thunks';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
||||
import dateFilterPredicate from 'Utilities/Date/dateFilterPredicate';
|
||||
import translate from 'Utilities/String/translate';
|
||||
@@ -128,8 +128,16 @@ export const filterPredicates = {
|
||||
|
||||
ratings: function(item, filterValue, type) {
|
||||
const predicate = filterTypePredicates[type];
|
||||
const { value = 0 } = item.ratings;
|
||||
|
||||
return predicate(item.ratings.value * 10, filterValue);
|
||||
return predicate(value * 10, filterValue);
|
||||
},
|
||||
|
||||
ratingVotes: function(item, filterValue, type) {
|
||||
const predicate = filterTypePredicates[type];
|
||||
const { votes = 0 } = item.ratings;
|
||||
|
||||
return predicate(votes, filterValue);
|
||||
},
|
||||
|
||||
originalLanguage: function(item, filterValue, type) {
|
||||
@@ -246,7 +254,7 @@ export const filterBuilderProps = [
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return tagList.sort(sortByName);
|
||||
return tagList.sort(sortByProp('name'));
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -315,7 +323,7 @@ export const filterBuilderProps = [
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return tagList.sort(sortByName);
|
||||
return tagList.sort(sortByProp('name'));
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -334,7 +342,7 @@ export const filterBuilderProps = [
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return languageList.sort(sortByName);
|
||||
return languageList.sort(sortByProp('name'));
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -347,6 +355,11 @@ export const filterBuilderProps = [
|
||||
label: () => translate('Rating'),
|
||||
type: filterBuilderTypes.NUMBER
|
||||
},
|
||||
{
|
||||
name: 'ratingVotes',
|
||||
label: () => translate('RatingVotes'),
|
||||
type: filterBuilderTypes.NUMBER
|
||||
},
|
||||
{
|
||||
name: 'certification',
|
||||
label: () => translate('Certification'),
|
||||
|
||||
@@ -29,6 +29,7 @@ export const defaultState = {
|
||||
showTitle: false,
|
||||
showMonitored: true,
|
||||
showQualityProfile: true,
|
||||
showTags: false,
|
||||
showSearchAction: false
|
||||
},
|
||||
|
||||
@@ -43,6 +44,7 @@ export const defaultState = {
|
||||
showSeasonCount: true,
|
||||
showPath: false,
|
||||
showSizeOnDisk: false,
|
||||
showTags: false,
|
||||
showSearchAction: false
|
||||
},
|
||||
|
||||
|
||||
@@ -2,13 +2,17 @@ import { createSelector } from 'reselect';
|
||||
import { DownloadClientAppState } from 'App/State/SettingsAppState';
|
||||
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import sortByName from 'Utilities/Array/sortByName';
|
||||
import DownloadClient from 'typings/DownloadClient';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
|
||||
export default function createEnabledDownloadClientsSelector(
|
||||
protocol: DownloadProtocol
|
||||
) {
|
||||
return createSelector(
|
||||
createSortedSectionSelector('settings.downloadClients', sortByName),
|
||||
createSortedSectionSelector<DownloadClient>(
|
||||
'settings.downloadClients',
|
||||
sortByProp('name')
|
||||
),
|
||||
(downloadClients: DownloadClientAppState) => {
|
||||
const { isFetching, isPopulated, error, items } = downloadClients;
|
||||
|
||||
|
||||
@@ -2,12 +2,11 @@ import { createSelector } from 'reselect';
|
||||
import RootFolderAppState from 'App/State/RootFolderAppState';
|
||||
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
|
||||
import RootFolder from 'typings/RootFolder';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
|
||||
export default function createRootFoldersSelector() {
|
||||
return createSelector(
|
||||
createSortedSectionSelector('rootFolders', (a: RootFolder, b: RootFolder) =>
|
||||
a.path.localeCompare(b.path)
|
||||
),
|
||||
createSortedSectionSelector<RootFolder>('rootFolders', sortByProp('path')),
|
||||
(rootFolders: RootFolderAppState) => rootFolders
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import getSectionState from 'Utilities/State/getSectionState';
|
||||
|
||||
function createSortedSectionSelector(section, comparer) {
|
||||
function createSortedSectionSelector<T>(
|
||||
section: string,
|
||||
comparer: (a: T, b: T) => number
|
||||
) {
|
||||
return createSelector(
|
||||
(state) => state,
|
||||
(state) => {
|
||||
const sectionState = getSectionState(state, section, true);
|
||||
|
||||
return {
|
||||
...sectionState,
|
||||
items: [...sectionState.items].sort(comparer)
|
||||
items: [...sectionState.items].sort(comparer),
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -3,6 +3,7 @@ import { useSelector } from 'react-redux';
|
||||
import { CommandBody } from 'Commands/Command';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import createMultiSeriesSelector from 'Store/Selectors/createMultiSeriesSelector';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './QueuedTaskRowNameCell.css';
|
||||
|
||||
@@ -39,9 +40,7 @@ export default function QueuedTaskRowNameCell(
|
||||
}
|
||||
|
||||
const series = useSelector(createMultiSeriesSelector(seriesIds));
|
||||
const sortedSeries = series.sort((a, b) =>
|
||||
a.sortTitle.localeCompare(b.sortTitle)
|
||||
);
|
||||
const sortedSeries = series.sort(sortByProp('sortTitle'));
|
||||
|
||||
return (
|
||||
<TableRowCell>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import styles from './UpdateChanges.css';
|
||||
|
||||
class UpdateChanges extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
title,
|
||||
changes
|
||||
} = this.props;
|
||||
|
||||
if (changes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.title}>{title}</div>
|
||||
<ul>
|
||||
{
|
||||
changes.map((change, index) => {
|
||||
return (
|
||||
<li key={index}>
|
||||
<InlineMarkdown data={change} />
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
UpdateChanges.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
changes: PropTypes.arrayOf(PropTypes.string)
|
||||
};
|
||||
|
||||
export default UpdateChanges;
|
||||
33
frontend/src/System/Updates/UpdateChanges.tsx
Normal file
33
frontend/src/System/Updates/UpdateChanges.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import styles from './UpdateChanges.css';
|
||||
|
||||
interface UpdateChangesProps {
|
||||
title: string;
|
||||
changes: string[];
|
||||
}
|
||||
|
||||
function UpdateChanges(props: UpdateChangesProps) {
|
||||
const { title, changes } = props;
|
||||
|
||||
if (changes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.title}>{title}</div>
|
||||
<ul>
|
||||
{changes.map((change, index) => {
|
||||
return (
|
||||
<li key={index}>
|
||||
<InlineMarkdown data={change} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UpdateChanges;
|
||||
@@ -1,249 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import Icon from 'Components/Icon';
|
||||
import Label from 'Components/Label';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import formatDate from 'Utilities/Date/formatDate';
|
||||
import formatDateTime from 'Utilities/Date/formatDateTime';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import UpdateChanges from './UpdateChanges';
|
||||
import styles from './Updates.css';
|
||||
|
||||
class Updates extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
currentVersion,
|
||||
isFetching,
|
||||
isPopulated,
|
||||
updatesError,
|
||||
generalSettingsError,
|
||||
items,
|
||||
isInstallingUpdate,
|
||||
updateMechanism,
|
||||
updateMechanismMessage,
|
||||
shortDateFormat,
|
||||
longDateFormat,
|
||||
timeFormat,
|
||||
onInstallLatestPress
|
||||
} = this.props;
|
||||
|
||||
const hasError = !!(updatesError || generalSettingsError);
|
||||
const hasUpdates = isPopulated && !hasError && items.length > 0;
|
||||
const noUpdates = isPopulated && !hasError && !items.length;
|
||||
const hasUpdateToInstall = hasUpdates && _.some(items, { installable: true, latest: true });
|
||||
const noUpdateToInstall = hasUpdates && !hasUpdateToInstall;
|
||||
|
||||
const externalUpdaterPrefix = translate('UpdateSonarrDirectlyLoadError');
|
||||
const externalUpdaterMessages = {
|
||||
external: translate('ExternalUpdater'),
|
||||
apt: translate('AptUpdater'),
|
||||
docker: translate('DockerUpdater')
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContent title={translate('Updates')}>
|
||||
<PageContentBody>
|
||||
{
|
||||
!isPopulated && !hasError &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
{
|
||||
noUpdates &&
|
||||
<Alert kind={kinds.INFO}>
|
||||
{translate('NoUpdatesAreAvailable')}
|
||||
</Alert>
|
||||
}
|
||||
|
||||
{
|
||||
hasUpdateToInstall &&
|
||||
<div className={styles.messageContainer}>
|
||||
{
|
||||
updateMechanism === 'builtIn' || updateMechanism === 'script' ?
|
||||
<SpinnerButton
|
||||
className={styles.updateAvailable}
|
||||
kind={kinds.PRIMARY}
|
||||
isSpinning={isInstallingUpdate}
|
||||
onPress={onInstallLatestPress}
|
||||
>
|
||||
{translate('InstallLatest')}
|
||||
</SpinnerButton> :
|
||||
|
||||
<Fragment>
|
||||
<Icon
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
size={30}
|
||||
/>
|
||||
|
||||
<div className={styles.message}>
|
||||
{externalUpdaterPrefix} <InlineMarkdown data={updateMechanismMessage || externalUpdaterMessages[updateMechanism] || externalUpdaterMessages.external} />
|
||||
</div>
|
||||
</Fragment>
|
||||
}
|
||||
|
||||
{
|
||||
isFetching &&
|
||||
<LoadingIndicator
|
||||
className={styles.loading}
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
noUpdateToInstall &&
|
||||
<div className={styles.messageContainer}>
|
||||
<Icon
|
||||
className={styles.upToDateIcon}
|
||||
name={icons.CHECK_CIRCLE}
|
||||
size={30}
|
||||
/>
|
||||
<div className={styles.message}>
|
||||
{translate('OnLatestVersion')}
|
||||
</div>
|
||||
|
||||
{
|
||||
isFetching &&
|
||||
<LoadingIndicator
|
||||
className={styles.loading}
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
hasUpdates &&
|
||||
<div>
|
||||
{
|
||||
items.map((update) => {
|
||||
const hasChanges = !!update.changes;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={update.version}
|
||||
className={styles.update}
|
||||
>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.version}>{update.version}</div>
|
||||
<div className={styles.space}>—</div>
|
||||
<div
|
||||
className={styles.date}
|
||||
title={formatDateTime(update.releaseDate, longDateFormat, timeFormat)}
|
||||
>
|
||||
{formatDate(update.releaseDate, shortDateFormat)}
|
||||
</div>
|
||||
|
||||
{
|
||||
update.branch === 'main' ?
|
||||
null :
|
||||
<Label
|
||||
className={styles.label}
|
||||
>
|
||||
{update.branch}
|
||||
</Label>
|
||||
}
|
||||
|
||||
{
|
||||
update.version === currentVersion ?
|
||||
<Label
|
||||
className={styles.label}
|
||||
kind={kinds.SUCCESS}
|
||||
title={formatDateTime(update.installedOn, longDateFormat, timeFormat)}
|
||||
>
|
||||
{translate('CurrentlyInstalled')}
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
update.version !== currentVersion && update.installedOn ?
|
||||
<Label
|
||||
className={styles.label}
|
||||
kind={kinds.INVERSE}
|
||||
title={formatDateTime(update.installedOn, longDateFormat, timeFormat)}
|
||||
>
|
||||
{translate('PreviouslyInstalled')}
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
!hasChanges &&
|
||||
<div>
|
||||
{translate('MaintenanceRelease')}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
hasChanges &&
|
||||
<div className={styles.changes}>
|
||||
<UpdateChanges
|
||||
title={translate('New')}
|
||||
changes={update.changes.new}
|
||||
/>
|
||||
|
||||
<UpdateChanges
|
||||
title={translate('Fixed')}
|
||||
changes={update.changes.fixed}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
!!updatesError &&
|
||||
<div>
|
||||
{translate('FailedToFetchUpdates')}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
!!generalSettingsError &&
|
||||
<div>
|
||||
{translate('FailedToUpdateSettings')}
|
||||
</div>
|
||||
}
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Updates.propTypes = {
|
||||
currentVersion: PropTypes.string.isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
updatesError: PropTypes.object,
|
||||
generalSettingsError: PropTypes.object,
|
||||
items: PropTypes.array.isRequired,
|
||||
isInstallingUpdate: PropTypes.bool.isRequired,
|
||||
updateMechanism: PropTypes.string,
|
||||
updateMechanismMessage: PropTypes.string,
|
||||
shortDateFormat: PropTypes.string.isRequired,
|
||||
longDateFormat: PropTypes.string.isRequired,
|
||||
timeFormat: PropTypes.string.isRequired,
|
||||
onInstallLatestPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default Updates;
|
||||
305
frontend/src/System/Updates/Updates.tsx
Normal file
305
frontend/src/System/Updates/Updates.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import React, {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import Alert from 'Components/Alert';
|
||||
import Icon from 'Components/Icon';
|
||||
import Label from 'Components/Label';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { fetchGeneralSettings } from 'Store/Actions/settingsActions';
|
||||
import { fetchUpdates } from 'Store/Actions/systemActions';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import { UpdateMechanism } from 'typings/Settings/General';
|
||||
import formatDate from 'Utilities/Date/formatDate';
|
||||
import formatDateTime from 'Utilities/Date/formatDateTime';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import UpdateChanges from './UpdateChanges';
|
||||
import styles from './Updates.css';
|
||||
|
||||
const VERSION_REGEX = /\d+\.\d+\.\d+\.\d+/i;
|
||||
|
||||
function createUpdatesSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.system.updates,
|
||||
(state: AppState) => state.settings.general,
|
||||
(updates, generalSettings) => {
|
||||
const { error: updatesError, items } = updates;
|
||||
|
||||
const isFetching = updates.isFetching || generalSettings.isFetching;
|
||||
const isPopulated = updates.isPopulated && generalSettings.isPopulated;
|
||||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
updatesError,
|
||||
generalSettingsError: generalSettings.error,
|
||||
items,
|
||||
updateMechanism: generalSettings.item.updateMechanism,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function Updates() {
|
||||
const currentVersion = useSelector((state: AppState) => state.app.version);
|
||||
const { packageUpdateMechanismMessage } = useSelector(
|
||||
createSystemStatusSelector()
|
||||
);
|
||||
const { shortDateFormat, longDateFormat, timeFormat } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
const isInstallingUpdate = useSelector(
|
||||
createCommandExecutingSelector(commandNames.APPLICATION_UPDATE)
|
||||
);
|
||||
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
updatesError,
|
||||
generalSettingsError,
|
||||
items,
|
||||
updateMechanism,
|
||||
} = useSelector(createUpdatesSelector());
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [isMajorUpdateModalOpen, setIsMajorUpdateModalOpen] = useState(false);
|
||||
const hasError = !!(updatesError || generalSettingsError);
|
||||
const hasUpdates = isPopulated && !hasError && items.length > 0;
|
||||
const noUpdates = isPopulated && !hasError && !items.length;
|
||||
|
||||
const externalUpdaterPrefix = translate('UpdateSonarrDirectlyLoadError');
|
||||
const externalUpdaterMessages: Partial<Record<UpdateMechanism, string>> = {
|
||||
external: translate('ExternalUpdater'),
|
||||
apt: translate('AptUpdater'),
|
||||
docker: translate('DockerUpdater'),
|
||||
};
|
||||
|
||||
const { isMajorUpdate, hasUpdateToInstall } = useMemo(() => {
|
||||
const majorVersion = parseInt(
|
||||
currentVersion.match(VERSION_REGEX)?.[0] ?? '0'
|
||||
);
|
||||
|
||||
const latestVersion = items[0]?.version;
|
||||
const latestMajorVersion = parseInt(
|
||||
latestVersion?.match(VERSION_REGEX)?.[0] ?? '0'
|
||||
);
|
||||
|
||||
return {
|
||||
isMajorUpdate: latestMajorVersion > majorVersion,
|
||||
hasUpdateToInstall: items.some(
|
||||
(update) => update.installable && update.latest
|
||||
),
|
||||
};
|
||||
}, [currentVersion, items]);
|
||||
|
||||
const noUpdateToInstall = hasUpdates && !hasUpdateToInstall;
|
||||
|
||||
const handleInstallLatestPress = useCallback(() => {
|
||||
if (isMajorUpdate) {
|
||||
setIsMajorUpdateModalOpen(true);
|
||||
} else {
|
||||
dispatch(executeCommand({ name: commandNames.APPLICATION_UPDATE }));
|
||||
}
|
||||
}, [isMajorUpdate, setIsMajorUpdateModalOpen, dispatch]);
|
||||
|
||||
const handleInstallLatestMajorVersionPress = useCallback(() => {
|
||||
setIsMajorUpdateModalOpen(false);
|
||||
|
||||
dispatch(
|
||||
executeCommand({
|
||||
name: commandNames.APPLICATION_UPDATE,
|
||||
installMajorUpdate: true,
|
||||
})
|
||||
);
|
||||
}, [setIsMajorUpdateModalOpen, dispatch]);
|
||||
|
||||
const handleCancelMajorVersionPress = useCallback(() => {
|
||||
setIsMajorUpdateModalOpen(false);
|
||||
}, [setIsMajorUpdateModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchUpdates());
|
||||
dispatch(fetchGeneralSettings());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<PageContent title={translate('Updates')}>
|
||||
<PageContentBody>
|
||||
{isPopulated || hasError ? null : <LoadingIndicator />}
|
||||
|
||||
{noUpdates ? (
|
||||
<Alert kind={kinds.INFO}>{translate('NoUpdatesAreAvailable')}</Alert>
|
||||
) : null}
|
||||
|
||||
{hasUpdateToInstall ? (
|
||||
<div className={styles.messageContainer}>
|
||||
{updateMechanism === 'builtIn' || updateMechanism === 'script' ? (
|
||||
<SpinnerButton
|
||||
kind={kinds.PRIMARY}
|
||||
isSpinning={isInstallingUpdate}
|
||||
onPress={handleInstallLatestPress}
|
||||
>
|
||||
{translate('InstallLatest')}
|
||||
</SpinnerButton>
|
||||
) : (
|
||||
<Fragment>
|
||||
<Icon name={icons.WARNING} kind={kinds.WARNING} size={30} />
|
||||
|
||||
<div className={styles.message}>
|
||||
{externalUpdaterPrefix}{' '}
|
||||
<InlineMarkdown
|
||||
data={
|
||||
packageUpdateMechanismMessage ||
|
||||
externalUpdaterMessages[updateMechanism] ||
|
||||
externalUpdaterMessages.external
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{isFetching ? (
|
||||
<LoadingIndicator className={styles.loading} size={20} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{noUpdateToInstall && (
|
||||
<div className={styles.messageContainer}>
|
||||
<Icon
|
||||
className={styles.upToDateIcon}
|
||||
name={icons.CHECK_CIRCLE}
|
||||
size={30}
|
||||
/>
|
||||
<div className={styles.message}>{translate('OnLatestVersion')}</div>
|
||||
|
||||
{isFetching && (
|
||||
<LoadingIndicator className={styles.loading} size={20} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasUpdates && (
|
||||
<div>
|
||||
{items.map((update) => {
|
||||
const hasChanges = !!update.changes;
|
||||
|
||||
return (
|
||||
<div key={update.version} className={styles.update}>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.version}>{update.version}</div>
|
||||
<div className={styles.space}>—</div>
|
||||
<div
|
||||
className={styles.date}
|
||||
title={formatDateTime(
|
||||
update.releaseDate,
|
||||
longDateFormat,
|
||||
timeFormat
|
||||
)}
|
||||
>
|
||||
{formatDate(update.releaseDate, shortDateFormat)}
|
||||
</div>
|
||||
|
||||
{update.branch === 'main' ? null : (
|
||||
<Label className={styles.label}>{update.branch}</Label>
|
||||
)}
|
||||
|
||||
{update.version === currentVersion ? (
|
||||
<Label
|
||||
className={styles.label}
|
||||
kind={kinds.SUCCESS}
|
||||
title={formatDateTime(
|
||||
update.installedOn,
|
||||
longDateFormat,
|
||||
timeFormat
|
||||
)}
|
||||
>
|
||||
{translate('CurrentlyInstalled')}
|
||||
</Label>
|
||||
) : null}
|
||||
|
||||
{update.version !== currentVersion && update.installedOn ? (
|
||||
<Label
|
||||
className={styles.label}
|
||||
kind={kinds.INVERSE}
|
||||
title={formatDateTime(
|
||||
update.installedOn,
|
||||
longDateFormat,
|
||||
timeFormat
|
||||
)}
|
||||
>
|
||||
{translate('PreviouslyInstalled')}
|
||||
</Label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasChanges ? (
|
||||
<div>
|
||||
<UpdateChanges
|
||||
title={translate('New')}
|
||||
changes={update.changes.new}
|
||||
/>
|
||||
|
||||
<UpdateChanges
|
||||
title={translate('Fixed')}
|
||||
changes={update.changes.fixed}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>{translate('MaintenanceRelease')}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updatesError ? <div>{translate('FailedToFetchUpdates')}</div> : null}
|
||||
|
||||
{generalSettingsError ? (
|
||||
<div>{translate('FailedToUpdateSettings')}</div>
|
||||
) : null}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isMajorUpdateModalOpen}
|
||||
kind={kinds.WARNING}
|
||||
title={translate('InstallMajorVersionUpdate')}
|
||||
message={
|
||||
<div>
|
||||
<div>{translate('InstallMajorVersionUpdateMessage')}</div>
|
||||
<div>
|
||||
<InlineMarkdown
|
||||
data={translate('InstallMajorVersionUpdateMessageLink', {
|
||||
domain: 'sonarr.tv',
|
||||
url: 'https://sonarr.tv/#downloads',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
confirmLabel={translate('Install')}
|
||||
onConfirm={handleInstallLatestMajorVersionPress}
|
||||
onCancel={handleCancelMajorVersionPress}
|
||||
/>
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default Updates;
|
||||
@@ -1,98 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { fetchGeneralSettings } from 'Store/Actions/settingsActions';
|
||||
import { fetchUpdates } from 'Store/Actions/systemActions';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import Updates from './Updates';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.app.version,
|
||||
createSystemStatusSelector(),
|
||||
(state) => state.system.updates,
|
||||
(state) => state.settings.general,
|
||||
createUISettingsSelector(),
|
||||
createCommandExecutingSelector(commandNames.APPLICATION_UPDATE),
|
||||
(
|
||||
currentVersion,
|
||||
status,
|
||||
updates,
|
||||
generalSettings,
|
||||
uiSettings,
|
||||
isInstallingUpdate
|
||||
) => {
|
||||
const {
|
||||
error: updatesError,
|
||||
items
|
||||
} = updates;
|
||||
|
||||
const isFetching = updates.isFetching || generalSettings.isFetching;
|
||||
const isPopulated = updates.isPopulated && generalSettings.isPopulated;
|
||||
|
||||
return {
|
||||
currentVersion,
|
||||
isFetching,
|
||||
isPopulated,
|
||||
updatesError,
|
||||
generalSettingsError: generalSettings.error,
|
||||
items,
|
||||
isInstallingUpdate,
|
||||
updateMechanism: generalSettings.item.updateMechanism,
|
||||
updateMechanismMessage: status.packageUpdateMechanismMessage,
|
||||
shortDateFormat: uiSettings.shortDateFormat,
|
||||
longDateFormat: uiSettings.longDateFormat,
|
||||
timeFormat: uiSettings.timeFormat
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
dispatchFetchUpdates: fetchUpdates,
|
||||
dispatchFetchGeneralSettings: fetchGeneralSettings,
|
||||
dispatchExecuteCommand: executeCommand
|
||||
};
|
||||
|
||||
class UpdatesConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatchFetchUpdates();
|
||||
this.props.dispatchFetchGeneralSettings();
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInstallLatestPress = () => {
|
||||
this.props.dispatchExecuteCommand({ name: commandNames.APPLICATION_UPDATE });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Updates
|
||||
onInstallLatestPress={this.onInstallLatestPress}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
UpdatesConnector.propTypes = {
|
||||
dispatchFetchUpdates: PropTypes.func.isRequired,
|
||||
dispatchFetchGeneralSettings: PropTypes.func.isRequired,
|
||||
dispatchExecuteCommand: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(UpdatesConnector);
|
||||
@@ -1,5 +0,0 @@
|
||||
function sortByName(a, b) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
export default sortByName;
|
||||
13
frontend/src/Utilities/Array/sortByProp.ts
Normal file
13
frontend/src/Utilities/Array/sortByProp.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { StringKey } from 'typings/Helpers/KeysMatching';
|
||||
|
||||
export function sortByProp<
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
T extends Record<K, string>,
|
||||
K extends StringKey<T>
|
||||
>(sortKey: K) {
|
||||
return (a: T, b: T) => {
|
||||
return a[sortKey].localeCompare(b[sortKey], undefined, { numeric: true });
|
||||
};
|
||||
}
|
||||
|
||||
export default sortByProp;
|
||||
7
frontend/src/typings/Helpers/KeysMatching.ts
Normal file
7
frontend/src/typings/Helpers/KeysMatching.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
type KeysMatching<T, V> = {
|
||||
[K in keyof T]-?: T[K] extends V ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
export type StringKey<T> = KeysMatching<T, string>;
|
||||
|
||||
export default KeysMatching;
|
||||
45
frontend/src/typings/Settings/General.ts
Normal file
45
frontend/src/typings/Settings/General.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export type UpdateMechanism =
|
||||
| 'builtIn'
|
||||
| 'script'
|
||||
| 'external'
|
||||
| 'apt'
|
||||
| 'docker';
|
||||
|
||||
export default interface General {
|
||||
bindAddress: string;
|
||||
port: number;
|
||||
sslPort: number;
|
||||
enableSsl: boolean;
|
||||
launchBrowser: boolean;
|
||||
authenticationMethod: string;
|
||||
authenticationRequired: string;
|
||||
analyticsEnabled: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
passwordConfirmation: string;
|
||||
logLevel: string;
|
||||
consoleLogLevel: string;
|
||||
branch: string;
|
||||
apiKey: string;
|
||||
sslCertPath: string;
|
||||
sslCertPassword: string;
|
||||
urlBase: string;
|
||||
instanceName: string;
|
||||
applicationUrl: string;
|
||||
updateAutomatically: boolean;
|
||||
updateMechanism: UpdateMechanism;
|
||||
updateScriptPath: string;
|
||||
proxyEnabled: boolean;
|
||||
proxyType: string;
|
||||
proxyHostname: string;
|
||||
proxyPort: number;
|
||||
proxyUsername: string;
|
||||
proxyPassword: string;
|
||||
proxyBypassFilter: string;
|
||||
proxyBypassLocalAddresses: boolean;
|
||||
certificateValidation: string;
|
||||
backupFolder: string;
|
||||
backupInterval: number;
|
||||
backupRetention: number;
|
||||
id: number;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface UiSettings {
|
||||
export default interface UiSettings {
|
||||
theme: 'auto' | 'dark' | 'light';
|
||||
showRelativeDates: boolean;
|
||||
shortDateFormat: string;
|
||||
@@ -19,6 +19,7 @@ interface SystemStatus {
|
||||
osName: string;
|
||||
osVersion: string;
|
||||
packageUpdateMechanism: string;
|
||||
packageUpdateMechanismMessage: string;
|
||||
runtimeName: string;
|
||||
runtimeVersion: string;
|
||||
sqliteVersion: string;
|
||||
|
||||
20
frontend/src/typings/Update.ts
Normal file
20
frontend/src/typings/Update.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface Changes {
|
||||
new: string[];
|
||||
fixed: string[];
|
||||
}
|
||||
|
||||
interface Update {
|
||||
version: string;
|
||||
branch: string;
|
||||
releaseDate: string;
|
||||
fileName: string;
|
||||
url: string;
|
||||
installed: boolean;
|
||||
installedOn: string;
|
||||
installable: boolean;
|
||||
latest: boolean;
|
||||
changes: Changes;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export default Update;
|
||||
@@ -71,6 +71,10 @@ namespace NzbDrone.Core.Test.Download.CompletedDownloadServiceTests
|
||||
Mocker.GetMock<IProvideImportItemService>()
|
||||
.Setup(s => s.ProvideImportItem(It.IsAny<DownloadClientItem>(), It.IsAny<DownloadClientItem>()))
|
||||
.Returns<DownloadClientItem, DownloadClientItem>((i, p) => i);
|
||||
|
||||
Mocker.GetMock<IEpisodeService>()
|
||||
.Setup(s => s.GetEpisodes(It.IsAny<IEnumerable<int>>()))
|
||||
.Returns(new List<Episode>());
|
||||
}
|
||||
|
||||
private RemoteEpisode BuildRemoteEpisode()
|
||||
|
||||
@@ -560,6 +560,34 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
|
||||
result.OutputRootFolders.First().Should().Be(@"C:\Downloads\Finished\QBittorrent".AsOsAgnostic());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_correct_category_output_path()
|
||||
{
|
||||
var config = new QBittorrentPreferences
|
||||
{
|
||||
SavePath = @"C:\Downloads\Finished\QBittorrent".AsOsAgnostic()
|
||||
};
|
||||
|
||||
Mocker.GetMock<IQBittorrentProxy>()
|
||||
.Setup(v => v.GetConfig(It.IsAny<QBittorrentSettings>()))
|
||||
.Returns(config);
|
||||
|
||||
Mocker.GetMock<IQBittorrentProxy>()
|
||||
.Setup(v => v.GetApiVersion(It.IsAny<QBittorrentSettings>()))
|
||||
.Returns(new Version(2, 0));
|
||||
|
||||
Mocker.GetMock<IQBittorrentProxy>()
|
||||
.Setup(s => s.GetLabels(It.IsAny<QBittorrentSettings>()))
|
||||
.Returns(new Dictionary<string, QBittorrentLabel>
|
||||
{ { "tv", new QBittorrentLabel { Name = "tv", SavePath = "//server/store/downloads" } } });
|
||||
|
||||
var result = Subject.GetStatus();
|
||||
|
||||
result.IsLocalhost.Should().BeTrue();
|
||||
result.OutputRootFolders.Should().NotBeNull();
|
||||
result.OutputRootFolders.First().Should().Be(@"\\server\store\downloads");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Download_should_handle_http_redirect_to_magnet()
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ using NzbDrone.Core.HealthCheck.Checks;
|
||||
using NzbDrone.Core.Localization;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
using NzbDrone.Core.Update;
|
||||
using NzbDrone.Test.Common;
|
||||
|
||||
namespace NzbDrone.Core.Test.HealthCheck.Checks
|
||||
{
|
||||
@@ -21,28 +22,10 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks
|
||||
.Returns("Some Warning Message");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_error_when_app_folder_is_write_protected()
|
||||
{
|
||||
WindowsOnly();
|
||||
|
||||
Mocker.GetMock<IAppFolderInfo>()
|
||||
.Setup(s => s.StartUpFolder)
|
||||
.Returns(@"C:\NzbDrone");
|
||||
|
||||
Mocker.GetMock<IDiskProvider>()
|
||||
.Setup(c => c.FolderWritable(It.IsAny<string>()))
|
||||
.Returns(false);
|
||||
|
||||
Subject.Check().ShouldBeError();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_error_when_app_folder_is_write_protected_and_update_automatically_is_enabled()
|
||||
{
|
||||
PosixOnly();
|
||||
|
||||
const string startupFolder = @"/opt/nzbdrone";
|
||||
var startupFolder = @"C:\NzbDrone".AsOsAgnostic();
|
||||
|
||||
Mocker.GetMock<IConfigFileProvider>()
|
||||
.Setup(s => s.UpdateAutomatically)
|
||||
@@ -62,10 +45,8 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks
|
||||
[Test]
|
||||
public void should_return_error_when_ui_folder_is_write_protected_and_update_automatically_is_enabled()
|
||||
{
|
||||
PosixOnly();
|
||||
|
||||
const string startupFolder = @"/opt/nzbdrone";
|
||||
const string uiFolder = @"/opt/nzbdrone/UI";
|
||||
var startupFolder = @"C:\NzbDrone".AsOsAgnostic();
|
||||
var uiFolder = @"C:\NzbDrone\UI".AsOsAgnostic();
|
||||
|
||||
Mocker.GetMock<IConfigFileProvider>()
|
||||
.Setup(s => s.UpdateAutomatically)
|
||||
@@ -89,7 +70,7 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks
|
||||
[Test]
|
||||
public void should_not_return_error_when_app_folder_is_write_protected_and_external_script_enabled()
|
||||
{
|
||||
PosixOnly();
|
||||
var startupFolder = @"C:\NzbDrone".AsOsAgnostic();
|
||||
|
||||
Mocker.GetMock<IConfigFileProvider>()
|
||||
.Setup(s => s.UpdateAutomatically)
|
||||
@@ -101,7 +82,7 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks
|
||||
|
||||
Mocker.GetMock<IAppFolderInfo>()
|
||||
.Setup(s => s.StartUpFolder)
|
||||
.Returns(@"/opt/nzbdrone");
|
||||
.Returns(startupFolder);
|
||||
|
||||
Mocker.GetMock<IDiskProvider>()
|
||||
.Verify(c => c.FolderWritable(It.IsAny<string>()), Times.Never());
|
||||
|
||||
@@ -34,6 +34,7 @@ namespace NzbDrone.Core.Test.NotificationTests.Xbmc
|
||||
Subject.Definition = new NotificationDefinition();
|
||||
Subject.Definition.Settings = new XbmcSettings
|
||||
{
|
||||
Host = "localhost",
|
||||
UpdateLibrary = true
|
||||
};
|
||||
}
|
||||
@@ -49,6 +50,7 @@ namespace NzbDrone.Core.Test.NotificationTests.Xbmc
|
||||
|
||||
Subject.Definition.Settings = new XbmcSettings
|
||||
{
|
||||
Host = "localhost",
|
||||
UpdateLibrary = true,
|
||||
CleanLibrary = true
|
||||
};
|
||||
@@ -58,6 +60,7 @@ namespace NzbDrone.Core.Test.NotificationTests.Xbmc
|
||||
public void should_not_clean_if_no_episode_was_replaced()
|
||||
{
|
||||
Subject.OnDownload(_downloadMessage);
|
||||
Subject.ProcessQueue();
|
||||
|
||||
Mocker.GetMock<IXbmcService>().Verify(v => v.Clean(It.IsAny<XbmcSettings>()), Times.Never());
|
||||
}
|
||||
@@ -67,6 +70,7 @@ namespace NzbDrone.Core.Test.NotificationTests.Xbmc
|
||||
{
|
||||
GivenOldFiles();
|
||||
Subject.OnDownload(_downloadMessage);
|
||||
Subject.ProcessQueue();
|
||||
|
||||
Mocker.GetMock<IXbmcService>().Verify(v => v.Clean(It.IsAny<XbmcSettings>()), Times.Once());
|
||||
}
|
||||
|
||||
@@ -72,6 +72,14 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
|
||||
[TestCase("I'm the Boss", "Im the Boss")]
|
||||
[TestCase("The Title's", "The Title's")]
|
||||
[TestCase("I'm after I'm", "Im after I'm")]
|
||||
[TestCase("I've Been Caught", "Ive Been Caught")]
|
||||
[TestCase("I'm Lost", "Im Lost")]
|
||||
[TestCase("That'll Be The Day", "Thatll Be The Day")]
|
||||
[TestCase("I'd Rather Be Alone", "Id Rather Be Alone")]
|
||||
[TestCase("I Can't Die", "I Cant Die")]
|
||||
[TestCase("Won`t Get Fooled Again", "Wont Get Fooled Again")]
|
||||
[TestCase("Don’t Blink", "Dont Blink")]
|
||||
[TestCase("The ` Legend of Kings", "The Legend of Kings")]
|
||||
|
||||
// [TestCase("", "")]
|
||||
public void should_get_expected_title_back(string title, string expected)
|
||||
|
||||
@@ -90,5 +90,18 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
|
||||
Subject.BuildFileName(new List<Episode> { _episode1 }, _series, _episodeFile)
|
||||
.Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestCase("Series: Title", ColonReplacementFormat.Custom, "\ua789", "Series\ua789 Title")]
|
||||
[TestCase("Series: Title", ColonReplacementFormat.Custom, "∶", "Series∶ Title")]
|
||||
public void should_replace_colon_with_custom_format(string seriesName, ColonReplacementFormat replacementFormat, string customFormat, string expected)
|
||||
{
|
||||
_series.Title = seriesName;
|
||||
_namingConfig.StandardEpisodeFormat = "{Series Title}";
|
||||
_namingConfig.ColonReplacementFormat = replacementFormat;
|
||||
_namingConfig.CustomColonReplacementFormat = customFormat;
|
||||
|
||||
Subject.BuildFileName(new List<Episode> { _episode1 }, _series, _episodeFile)
|
||||
.Should().Be(expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,8 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
[TestCase("[Mystic Z-Team] Series Title Super - Episode 013 VF - Non-censuré [720p].mp4", "Series Title Super", 13, 0, 0)]
|
||||
[TestCase("Series Title Kai Episodio 13 Audio Latino", "Series Title Kai", 13, 0, 0)]
|
||||
[TestCase("Series_Title_2_[01]_[AniLibria_TV]_[WEBRip_1080p]", "Series Title 2", 1, 0, 0)]
|
||||
[TestCase("[SubsPlease] Series Title - 100 Years Quest - 01 (1080p) [1107F3A9].mkv", "Series Title - 100 Years Quest", 1, 0, 0)]
|
||||
[TestCase("[SubsPlease] Series Title 100 Years Quest - 01 (1080p) [1107F3A9].mkv", "Series Title 100 Years Quest", 1, 0, 0)]
|
||||
|
||||
// [TestCase("", "", 0, 0, 0)]
|
||||
public void should_parse_absolute_numbers(string postTitle, string title, int absoluteEpisodeNumber, int seasonNumber, int episodeNumber)
|
||||
|
||||
@@ -254,6 +254,7 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
[TestCase("Series Title Season 2 (WEB 1080p HEVC Opus) [Netaro]", false)]
|
||||
[TestCase("Series.Title.S01E01.Erste.Begegnungen.German.DD51.Synced.DL.1080p.HBOMaxHD.AVC-TVS", false)]
|
||||
[TestCase("Series.Title.S01E05.Tavora.greift.an.German.DL.1080p.DisneyHD.h264-4SF", false)]
|
||||
[TestCase("Series.Title.S02E04.German.Dubbed.DL.AAC.1080p.WEB.AVC-GROUP", false)]
|
||||
public void should_parse_webdl1080p_quality(string title, bool proper)
|
||||
{
|
||||
ParseAndVerifyQuality(title, Quality.WEBDL1080p, proper);
|
||||
@@ -279,6 +280,7 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
[TestCase("[HorribleSubs] Series Title! S01 [Web][MKV][h264][2160p][AAC 2.0][Softsubs (HorribleSubs)]", false)]
|
||||
[TestCase("Series Title S02 2013 WEB-DL 4k H265 AAC 2Audio-HDSWEB", false)]
|
||||
[TestCase("Series.Title.S02E02.This.Year.Will.Be.Different.2160p.WEB.H.265", false)]
|
||||
[TestCase("Series.Title.S02E04.German.Dubbed.DL.AAC.2160p.DV.HDR.WEB.HEVC-GROUP", false)]
|
||||
public void should_parse_webdl2160p_quality(string title, bool proper)
|
||||
{
|
||||
ParseAndVerifyQuality(title, Quality.WEBDL2160p, proper);
|
||||
|
||||
@@ -51,6 +51,8 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
[TestCase("[Skymoon-Raws] Anime-Series Title S02 - 01 [ViuTV][CHT][WEB-DL][1080p][AVC AAC][MP4+ASS]", "Anime-Series Title S2", "Skymoon-Raws", 1)]
|
||||
[TestCase("[orion origin] Anime-Series Title S02[07][1080p][H264 AAC][CHS][ENG&JPN stidio]", "Anime-Series Title S2", "orion origin", 7)]
|
||||
[TestCase("[UHA-WINGS][Anime-Series Title S02][01][x264 1080p][CHT].mp4", "Anime-Series Title S2", "UHA-WINGS", 1)]
|
||||
[TestCase("[Suzuya Raws] 腼腆英雄 东京夺还篇 / Series 2nd Season - 01 [CR WebRip 1080p HEVC-10bit AAC][Multi-Subs]", "Series 2nd Season", "Suzuya Raws", 1)]
|
||||
[TestCase("[ANi] SERIES / SERIES 靦腆英雄 - 11 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]", "SERIES", "ANi", 11)]
|
||||
public void should_parse_chinese_anime_season_episode_releases(string postTitle, string title, string subgroup, int absoluteEpisodeNumber)
|
||||
{
|
||||
postTitle = XmlCleaner.ReplaceUnicode(postTitle);
|
||||
|
||||
@@ -5,6 +5,7 @@ using FizzWare.NBuilder;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.AutoTagging;
|
||||
using NzbDrone.Core.Organizer;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
using NzbDrone.Core.Tv;
|
||||
@@ -28,6 +29,10 @@ namespace NzbDrone.Core.Test.TvTests.SeriesServiceTests
|
||||
.With(s => s.Path = @"C:\Test\name".AsOsAgnostic())
|
||||
.With(s => s.RootFolderPath = "")
|
||||
.Build().ToList();
|
||||
|
||||
Mocker.GetMock<IAutoTaggingService>()
|
||||
.Setup(s => s.GetTagChanges(It.IsAny<Series>()))
|
||||
.Returns(new AutoTaggingChanges());
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -79,5 +84,23 @@ namespace NzbDrone.Core.Test.TvTests.SeriesServiceTests
|
||||
|
||||
Subject.UpdateSeries(series, false);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_add_and_remove_tags()
|
||||
{
|
||||
_series[0].Tags = new HashSet<int> { 1, 2 };
|
||||
|
||||
Mocker.GetMock<IAutoTaggingService>()
|
||||
.Setup(s => s.GetTagChanges(_series[0]))
|
||||
.Returns(new AutoTaggingChanges
|
||||
{
|
||||
TagsToAdd = new HashSet<int> { 3 },
|
||||
TagsToRemove = new HashSet<int> { 1 }
|
||||
});
|
||||
|
||||
var result = Subject.UpdateSeries(_series, false);
|
||||
|
||||
result[0].Tags.Should().BeEquivalentTo(new[] { 2, 3 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FizzWare.NBuilder;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.AutoTagging;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
using NzbDrone.Core.Tv;
|
||||
|
||||
@@ -31,6 +33,14 @@ namespace NzbDrone.Core.Test.TvTests.SeriesServiceTests
|
||||
new Season { SeasonNumber = 1, Monitored = true },
|
||||
new Season { SeasonNumber = 2, Monitored = true }
|
||||
};
|
||||
|
||||
Mocker.GetMock<IAutoTaggingService>()
|
||||
.Setup(s => s.GetTagChanges(It.IsAny<Series>()))
|
||||
.Returns(new AutoTaggingChanges());
|
||||
|
||||
Mocker.GetMock<ISeriesRepository>()
|
||||
.Setup(s => s.Update(It.IsAny<Series>()))
|
||||
.Returns<Series>(r => r);
|
||||
}
|
||||
|
||||
private void GivenExistingSeries()
|
||||
@@ -68,5 +78,28 @@ namespace NzbDrone.Core.Test.TvTests.SeriesServiceTests
|
||||
Mocker.GetMock<IEpisodeService>()
|
||||
.Verify(v => v.SetEpisodeMonitoredBySeason(_fakeSeries.Id, It.IsAny<int>(), It.IsAny<bool>()), Times.Once());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_add_and_remove_tags()
|
||||
{
|
||||
GivenExistingSeries();
|
||||
var seasonNumber = 1;
|
||||
var monitored = false;
|
||||
|
||||
_fakeSeries.Tags = new HashSet<int> { 1, 2 };
|
||||
_fakeSeries.Seasons.Single(s => s.SeasonNumber == seasonNumber).Monitored = monitored;
|
||||
|
||||
Mocker.GetMock<IAutoTaggingService>()
|
||||
.Setup(s => s.GetTagChanges(_fakeSeries))
|
||||
.Returns(new AutoTaggingChanges
|
||||
{
|
||||
TagsToAdd = new HashSet<int> { 3 },
|
||||
TagsToRemove = new HashSet<int> { 1 }
|
||||
});
|
||||
|
||||
var result = Subject.UpdateSeries(_fakeSeries);
|
||||
|
||||
result.Tags.Should().BeEquivalentTo(new[] { 2, 3 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ namespace NzbDrone.Core.Configuration
|
||||
}
|
||||
}
|
||||
|
||||
public bool UpdateAutomatically => _updateOptions.Automatically ?? GetValueBoolean("UpdateAutomatically", false, false);
|
||||
public bool UpdateAutomatically => _updateOptions.Automatically ?? GetValueBoolean("UpdateAutomatically", OsInfo.IsWindows, false);
|
||||
|
||||
public UpdateMechanism UpdateMechanism =>
|
||||
Enum.TryParse<UpdateMechanism>(_updateOptions.Mechanism, out var enumValue)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using FluentMigrator;
|
||||
using NzbDrone.Core.Datastore.Migration.Framework;
|
||||
|
||||
namespace NzbDrone.Core.Datastore.Migration
|
||||
{
|
||||
[Migration(209)]
|
||||
public class add_on_import_complete_to_notifications : NzbDroneMigrationBase
|
||||
{
|
||||
protected override void MainDbUpgrade()
|
||||
{
|
||||
Alter.Table("Notifications").AddColumn("OnImportComplete").AsBoolean().WithDefaultValue(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using FluentMigrator;
|
||||
using NzbDrone.Core.Datastore.Migration.Framework;
|
||||
|
||||
namespace NzbDrone.Core.Datastore.Migration
|
||||
{
|
||||
[Migration(211)]
|
||||
public class add_custom_colon_replacement_to_naming_config : NzbDroneMigrationBase
|
||||
{
|
||||
protected override void MainDbUpgrade()
|
||||
{
|
||||
Alter.Table("NamingConfig").AddColumn("CustomColonReplacementFormat").AsString().WithDefaultValue("");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,7 @@ namespace NzbDrone.Core.Datastore
|
||||
.Ignore(x => x.ImplementationName)
|
||||
.Ignore(i => i.SupportsOnGrab)
|
||||
.Ignore(i => i.SupportsOnDownload)
|
||||
.Ignore(i => i.SupportsOnImportComplete)
|
||||
.Ignore(i => i.SupportsOnUpgrade)
|
||||
.Ignore(i => i.SupportsOnRename)
|
||||
.Ignore(i => i.SupportsOnSeriesAdd)
|
||||
|
||||
@@ -377,7 +377,15 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
|
||||
{
|
||||
if (Proxy.GetLabels(Settings).TryGetValue(Settings.TvCategory, out var label) && label.SavePath.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
var labelDir = new OsPath(label.SavePath);
|
||||
var savePath = label.SavePath;
|
||||
|
||||
if (savePath.StartsWith("//"))
|
||||
{
|
||||
_logger.Trace("Replacing double forward slashes in path '{0}'. If this is not meant to be a Windows UNC path fix the 'Save Path' in qBittorrent's {1} category", savePath, Settings.TvCategory);
|
||||
savePath = savePath.Replace('/', '\\');
|
||||
}
|
||||
|
||||
var labelDir = new OsPath(savePath);
|
||||
|
||||
if (labelDir.IsRooted)
|
||||
{
|
||||
|
||||
@@ -34,6 +34,8 @@ namespace NzbDrone.Core.Download
|
||||
private readonly IParsingService _parsingService;
|
||||
private readonly ISeriesService _seriesService;
|
||||
private readonly ITrackedDownloadAlreadyImported _trackedDownloadAlreadyImported;
|
||||
private readonly IEpisodeService _episodeService;
|
||||
private readonly IMediaFileService _mediaFileService;
|
||||
private readonly Logger _logger;
|
||||
|
||||
public CompletedDownloadService(IEventAggregator eventAggregator,
|
||||
@@ -43,6 +45,8 @@ namespace NzbDrone.Core.Download
|
||||
IParsingService parsingService,
|
||||
ISeriesService seriesService,
|
||||
ITrackedDownloadAlreadyImported trackedDownloadAlreadyImported,
|
||||
IEpisodeService episodeService,
|
||||
IMediaFileService mediaFileService,
|
||||
Logger logger)
|
||||
{
|
||||
_eventAggregator = eventAggregator;
|
||||
@@ -52,6 +56,8 @@ namespace NzbDrone.Core.Download
|
||||
_parsingService = parsingService;
|
||||
_seriesService = seriesService;
|
||||
_trackedDownloadAlreadyImported = trackedDownloadAlreadyImported;
|
||||
_episodeService = episodeService;
|
||||
_mediaFileService = mediaFileService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -198,11 +204,23 @@ namespace NzbDrone.Core.Download
|
||||
.Count() >= Math.Max(1,
|
||||
trackedDownload.RemoteEpisode.Episodes.Count);
|
||||
|
||||
var historyItems = _historyService.FindByDownloadId(trackedDownload.DownloadItem.DownloadId)
|
||||
.OrderByDescending(h => h.Date)
|
||||
.ToList();
|
||||
|
||||
var grabbedHistory = historyItems.Where(h => h.EventType == EpisodeHistoryEventType.Grabbed).ToList();
|
||||
var releaseInfo = grabbedHistory.Count > 0 ? new GrabbedReleaseInfo(grabbedHistory) : null;
|
||||
|
||||
if (allEpisodesImported)
|
||||
{
|
||||
_logger.Debug("All episodes were imported for {0}", trackedDownload.DownloadItem.Title);
|
||||
trackedDownload.State = TrackedDownloadState.Imported;
|
||||
_eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload, trackedDownload.RemoteEpisode.Series.Id));
|
||||
|
||||
_eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload,
|
||||
trackedDownload.RemoteEpisode.Series.Id,
|
||||
importResults.Where(c => c.Result == ImportResultType.Imported).Select(c => c.EpisodeFile).ToList(),
|
||||
releaseInfo));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -216,11 +234,6 @@ namespace NzbDrone.Core.Download
|
||||
// safe, but commenting for future benefit.
|
||||
|
||||
var atLeastOneEpisodeImported = importResults.Any(c => c.Result == ImportResultType.Imported);
|
||||
|
||||
var historyItems = _historyService.FindByDownloadId(trackedDownload.DownloadItem.DownloadId)
|
||||
.OrderByDescending(h => h.Date)
|
||||
.ToList();
|
||||
|
||||
var allEpisodesImportedInHistory = _trackedDownloadAlreadyImported.IsImported(trackedDownload, historyItems);
|
||||
|
||||
if (allEpisodesImportedInHistory)
|
||||
@@ -244,8 +257,11 @@ namespace NzbDrone.Core.Download
|
||||
.Write();
|
||||
}
|
||||
|
||||
var episodes = _episodeService.GetEpisodes(trackedDownload.RemoteEpisode.Episodes.Select(e => e.Id));
|
||||
var files = _mediaFileService.GetFiles(episodes.Select(e => e.EpisodeFileId).Where(i => i > 0).Distinct());
|
||||
|
||||
trackedDownload.State = TrackedDownloadState.Imported;
|
||||
_eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload, trackedDownload.RemoteEpisode.Series.Id));
|
||||
_eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload, trackedDownload.RemoteEpisode.Series.Id, files, releaseInfo));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
using NzbDrone.Common.Messaging;
|
||||
using System.Collections.Generic;
|
||||
using NzbDrone.Common.Messaging;
|
||||
using NzbDrone.Core.Download.TrackedDownloads;
|
||||
using NzbDrone.Core.MediaFiles;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
|
||||
namespace NzbDrone.Core.Download
|
||||
{
|
||||
public class DownloadCompletedEvent : IEvent
|
||||
{
|
||||
public TrackedDownload TrackedDownload { get; private set; }
|
||||
public int SeriesId { get; set; }
|
||||
public int SeriesId { get; private set; }
|
||||
public List<EpisodeFile> EpisodeFiles { get; private set; }
|
||||
public GrabbedReleaseInfo Release { get; private set; }
|
||||
|
||||
public DownloadCompletedEvent(TrackedDownload trackedDownload, int seriesId)
|
||||
public DownloadCompletedEvent(TrackedDownload trackedDownload, int seriesId, List<EpisodeFile> episodeFiles, GrabbedReleaseInfo release)
|
||||
{
|
||||
TrackedDownload = trackedDownload;
|
||||
SeriesId = seriesId;
|
||||
EpisodeFiles = episodeFiles;
|
||||
Release = release;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Extensions;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
using NzbDrone.Common.Messaging;
|
||||
using NzbDrone.Core.MediaFiles;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Tv;
|
||||
|
||||
namespace NzbDrone.Core.Download
|
||||
{
|
||||
public class UntrackedDownloadCompletedEvent : IEvent
|
||||
{
|
||||
public Series Series { get; private set; }
|
||||
public List<Episode> Episodes { get; private set; }
|
||||
public List<EpisodeFile> EpisodeFiles { get; private set; }
|
||||
public ParsedEpisodeInfo ParsedEpisodeInfo { get; private set; }
|
||||
public string SourcePath { get; private set; }
|
||||
|
||||
public UntrackedDownloadCompletedEvent(Series series, List<Episode> episodes, List<EpisodeFile> episodeFiles, ParsedEpisodeInfo parsedEpisodeInfo, string sourcePath)
|
||||
{
|
||||
Series = series;
|
||||
Episodes = episodes;
|
||||
EpisodeFiles = episodeFiles;
|
||||
ParsedEpisodeInfo = parsedEpisodeInfo;
|
||||
SourcePath = sourcePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ namespace NzbDrone.Core.HealthCheck.Checks
|
||||
var startupFolder = _appFolderInfo.StartUpFolder;
|
||||
var uiFolder = Path.Combine(startupFolder, "UI");
|
||||
|
||||
if ((OsInfo.IsWindows || _configFileProvider.UpdateAutomatically) &&
|
||||
if (_configFileProvider.UpdateAutomatically &&
|
||||
_configFileProvider.UpdateMechanism == UpdateMechanism.BuiltIn &&
|
||||
!_osInfo.IsDocker)
|
||||
{
|
||||
|
||||
@@ -209,7 +209,7 @@
|
||||
"CountSeasons": "{count} Řad",
|
||||
"CustomFormat": "Vlastní formát",
|
||||
"CustomFormats": "Vlastní formáty",
|
||||
"CutoffUnmet": "Vynechat nevyhovující",
|
||||
"CutoffUnmet": "Mezní hodnota nesplněna",
|
||||
"Cutoff": "Odříznout",
|
||||
"DailyEpisodeFormat": "Formát denní epizody",
|
||||
"DailyEpisodeTypeDescription": "Epizody vydávané denně nebo méně často, které používají rok-měsíc-den (2023-08-04)",
|
||||
@@ -219,7 +219,7 @@
|
||||
"CreateGroup": "Vytvořit skupinu",
|
||||
"Custom": "Vlastní",
|
||||
"CustomFormatUnknownCondition": "Neznámá podmínka vlastního formátu „{implementation}“",
|
||||
"CustomFormatUnknownConditionOption": "Neznámá možnost '{key}' pro podmínku '{implementation}'",
|
||||
"CustomFormatUnknownConditionOption": "Neznámá možnost „{key}“ pro podmínku „{implementation}“",
|
||||
"CustomFormatsLoadError": "Nelze načíst vlastní formáty",
|
||||
"CustomFormatsSettingsSummary": "Vlastní formáty a nastavení",
|
||||
"CopyUsingHardlinksSeriesHelpText": "Pevné odkazy umožňují aplikaci {appName} importovat odesílané torrenty do složky seriálu, aniž by zabíraly další místo na disku nebo kopírovaly celý obsah souboru. Hardlinky budou fungovat pouze v případě, že zdrojový a cílový soubor jsou na stejném svazku",
|
||||
@@ -319,5 +319,17 @@
|
||||
"EditSelectedImportLists": "Upravit vybrané seznamy k importu",
|
||||
"FormatDateTime": "{formattedDate} {formattedTime}",
|
||||
"AddRootFolderError": "Nepodařilo se přidat kořenový adresář",
|
||||
"DownloadClientRemovesCompletedDownloadsHealthCheckMessage": "Klient stahování {downloadClientName} je nastaven na odstranění dokončených stahování. To může vést k tomu, že stahování budou z klienta odstraněna dříve, než je bude moci importovat {appName}."
|
||||
"DownloadClientRemovesCompletedDownloadsHealthCheckMessage": "Klient stahování {downloadClientName} je nastaven na odstranění dokončených stahování. To může vést k tomu, že stahování budou z klienta odstraněna dříve, než je bude moci importovat {appName}.",
|
||||
"ConnectionSettingsUrlBaseHelpText": "Přidá předponu do {connectionName} url, jako např. {url}",
|
||||
"CustomFormatsSpecificationRegularExpressionHelpText": "Vlastní formát RegEx nerozlišuje velká a malá písmena",
|
||||
"CustomFormatsSpecificationFlag": "Vlajka",
|
||||
"BlackholeFolderHelpText": "Složka do které {appName} uloží {extension} soubor",
|
||||
"BlackholeWatchFolder": "Složka sledování",
|
||||
"Category": "Kategorie",
|
||||
"BlocklistAndSearch": "Seznam blokovaných a vyhledávání",
|
||||
"BlackholeWatchFolderHelpText": "Složka ze které {appName} má importovat stažené soubory",
|
||||
"BlocklistReleaseHelpText": "Zabránit {appName} v opětovném sebrání tohoto vydání pomocí RSS nebo automatického vyhledávání",
|
||||
"BlocklistMultipleOnlyHint": "Blokovat a nehledat náhradu",
|
||||
"CustomFormatsSettingsTriggerInfo": "Vlastní formát se použije na vydání nebo soubor, pokud odpovídá alespoň jednomu z různých typů zvolených podmínek.",
|
||||
"ChangeCategory": "Změnit kategorii"
|
||||
}
|
||||
|
||||
@@ -265,11 +265,15 @@
|
||||
"CountSelectedFile": "{selectedCount} selected file",
|
||||
"CountSelectedFiles": "{selectedCount} selected files",
|
||||
"CountSeriesSelected": "{count} series selected",
|
||||
"CountVotes": "{votes} votes",
|
||||
"CreateEmptySeriesFolders": "Create Empty Series Folders",
|
||||
"CreateEmptySeriesFoldersHelpText": "Create missing series folders during disk scan",
|
||||
"CreateGroup": "Create Group",
|
||||
"CurrentlyInstalled": "Currently Installed",
|
||||
"Custom": "Custom",
|
||||
"CustomColonReplacement": "Custom Colon Replacement",
|
||||
"CustomColonReplacementFormatHelpText": "Characters to be used as a replacement for colons",
|
||||
"CustomColonReplacementFormatHint": "Valid file system character such as Colon (Letter)",
|
||||
"CustomFilter": "Custom Filter",
|
||||
"CustomFilters": "Custom Filters",
|
||||
"CustomFormat": "Custom Format",
|
||||
@@ -1019,7 +1023,11 @@
|
||||
"IndexersSettingsSummary": "Indexers and indexer options",
|
||||
"Info": "Info",
|
||||
"InfoUrl": "Info URL",
|
||||
"Install": "Install",
|
||||
"InstallLatest": "Install Latest",
|
||||
"InstallMajorVersionUpdate": "Install Update",
|
||||
"InstallMajorVersionUpdateMessage": "This update will install a new major version and may not be compatible with your system. Are you sure you want to install this update?",
|
||||
"InstallMajorVersionUpdateMessageLink": "Please check [{domain}]({url}) for more information.",
|
||||
"InstanceName": "Instance Name",
|
||||
"InstanceNameHelpText": "Instance name in tab and for Syslog app name",
|
||||
"InteractiveImport": "Interactive Import",
|
||||
@@ -1456,16 +1464,17 @@
|
||||
"OnApplicationUpdate": "On Application Update",
|
||||
"OnEpisodeFileDelete": "On Episode File Delete",
|
||||
"OnEpisodeFileDeleteForUpgrade": "On Episode File Delete For Upgrade",
|
||||
"OnFileImport": "On File Import",
|
||||
"OnFileUpgrade": "On File Upgrade",
|
||||
"OnGrab": "On Grab",
|
||||
"OnHealthIssue": "On Health Issue",
|
||||
"OnHealthRestored": "On Health Restored",
|
||||
"OnImport": "On Import",
|
||||
"OnImportComplete": "On Import Complete",
|
||||
"OnLatestVersion": "The latest version of {appName} is already installed",
|
||||
"OnManualInteractionRequired": "On Manual Interaction Required",
|
||||
"OnRename": "On Rename",
|
||||
"OnSeriesAdd": "On Series Add",
|
||||
"OnSeriesDelete": "On Series Delete",
|
||||
"OnUpgrade": "On Upgrade",
|
||||
"OneMinute": "1 Minute",
|
||||
"OneSeason": "1 Season",
|
||||
"OnlyForBulkSeasonReleases": "Only for Bulk Season Releases",
|
||||
@@ -1581,6 +1590,7 @@
|
||||
"QuickSearch": "Quick Search",
|
||||
"Range": "Range",
|
||||
"Rating": "Rating",
|
||||
"RatingVotes": "Rating Votes",
|
||||
"ReadTheWikiForMoreInformation": "Read the Wiki for more information",
|
||||
"Real": "Real",
|
||||
"Reason": "Reason",
|
||||
@@ -1858,6 +1868,8 @@
|
||||
"ShowSeasonCount": "Show Season Count",
|
||||
"ShowSeriesTitleHelpText": "Show series title under poster",
|
||||
"ShowSizeOnDisk": "Show Size on Disk",
|
||||
"ShowTags": "Show Tags",
|
||||
"ShowTagsHelpText": "Show tags under poster",
|
||||
"ShowTitle": "Show Title",
|
||||
"ShowUnknownSeriesItems": "Show Unknown Series Items",
|
||||
"ShowUnknownSeriesItemsHelpText": "Show items without a series in the queue, this could include removed series, movies or anything else in {appName}'s category",
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
"BackupRetentionHelpText": "Las copias de seguridad automáticas anteriores al período de retención serán borradas automáticamente",
|
||||
"AddNewSeries": "Añadir Nueva Serie",
|
||||
"AddNewSeriesError": "Falló al cargar los resultados de la búsqueda, inténtelo de nuevo.",
|
||||
"AddNewSeriesHelpText": "Es fácil añadir una nueva serie, empiece escribiendo el nombre de la serie que desea añadir.",
|
||||
"AddNewSeriesHelpText": "Es fácil añadir una nueva serie, empieza escribiendo el nombre de la serie que quieres añadir.",
|
||||
"AddNewSeriesRootFolderHelpText": "La subcarpeta '{folder}' será creada automáticamente",
|
||||
"AddNewSeriesSearchForCutoffUnmetEpisodes": "Empezar la búsqueda de episodios con límites no alcanzados",
|
||||
"AddNewSeriesSearchForMissingEpisodes": "Empezar la búsqueda de episodios faltantes",
|
||||
@@ -1150,7 +1150,7 @@
|
||||
"KeyboardShortcutsFocusSearchBox": "Enfocar Campo de Búsqueda",
|
||||
"KeyboardShortcutsOpenModal": "Abrir esta Ventana Modal",
|
||||
"Languages": "Idiomas",
|
||||
"LibraryImportSeriesHeader": "Importe las series que ya posee",
|
||||
"LibraryImportSeriesHeader": "Importa las series que ya tengas",
|
||||
"LibraryImportTips": "Algunos consejos para que la importación vaya sobre ruedas:",
|
||||
"IndexerValidationTestAbortedDueToError": "El test fue abortado debido a un error: {exceptionMessage}",
|
||||
"Large": "Grande",
|
||||
@@ -1182,10 +1182,10 @@
|
||||
"LogOnly": "Sólo Registro",
|
||||
"LongDateFormat": "Formato de Fecha Larga",
|
||||
"ManageEpisodesSeason": "Gestionar los archivos de Episodios de esta temporada",
|
||||
"LibraryImportTipsDontUseDownloadsFolder": "No lo utilice para importar descargas desde su cliente de descargas, esto es sólo para bibliotecas organizadas existentes, no para archivos sin clasificar.",
|
||||
"LibraryImportTipsDontUseDownloadsFolder": "No lo utilices para importar descargas desde tu cliente de descarga, esto es sólo para bibliotecas organizadas existentes, no para archivos sin clasificar.",
|
||||
"LocalAirDate": "Fecha de emisión local",
|
||||
"NotificationsEmailSettingsUseEncryptionHelpText": "Si prefiere utilizar el cifrado si está configurado en el servidor, utilizar siempre el cifrado mediante SSL (sólo puerto 465) o StartTLS (cualquier otro puerto) o no utilizar nunca el cifrado",
|
||||
"LibraryImportTipsSeriesUseRootFolder": "Dirija {appName} a la carpeta que contiene todas sus series de TV, no a una en concreto. Por ejemplo, \"`{goodFolderExample}`\" y no \"`{badFolderExample}`\". Además, cada serie debe estar en su propia carpeta dentro de la carpeta raíz/biblioteca.",
|
||||
"LibraryImportTipsSeriesUseRootFolder": "Dirije {appName} a la carpeta que contenga todas tus series de TV, no a una en concreto. Por ejemplo, \"`{goodFolderExample}`\" y no \"`{badFolderExample}`\". Además, cada serie debe estar en su propia carpeta dentro de la carpeta raíz/biblioteca.",
|
||||
"ListSyncTag": "Etiqueta de Sincronización de Lista",
|
||||
"ListSyncTagHelpText": "Esta etiqueta se añadirá cuando una serie desaparezca o ya no esté en su(s) lista(s)",
|
||||
"MetadataLoadError": "No se puede cargar Metadatos",
|
||||
@@ -1457,7 +1457,7 @@
|
||||
"Scene": "Escena",
|
||||
"RssSyncIntervalHelpText": "Intervalo en minutos. Configurar a cero para deshabilitar (esto detendrá todas las capturas automáticas de lanzamientos)",
|
||||
"SceneNumberNotVerified": "El número de escena no ha sido verificado aún",
|
||||
"SearchForAllMissingEpisodes": "Buscar todos los episodios perdidos",
|
||||
"SearchForAllMissingEpisodes": "Buscar todos los episodios faltantes",
|
||||
"SeasonInformation": "Información de temporada",
|
||||
"SeasonNumber": "Número de temporada",
|
||||
"SeasonCount": "Recuento de temporada",
|
||||
@@ -1526,7 +1526,6 @@
|
||||
"ShowQualityProfile": "Mostrar perfil de calidad",
|
||||
"ShowQualityProfileHelpText": "Muestra el perfil de calidad bajo el póster",
|
||||
"ShowRelativeDates": "Mostrar fechas relativas",
|
||||
"OnImport": "Al importar",
|
||||
"Other": "Otro",
|
||||
"ShowRelativeDatesHelpText": "Muestra fechas absolutas o relativas (Hoy/Ayer/etc)",
|
||||
"Proxy": "Proxy",
|
||||
@@ -1542,7 +1541,7 @@
|
||||
"SourcePath": "Ruta de la fuente",
|
||||
"SourceRelativePath": "Ruta relativa de la fuente",
|
||||
"Special": "Especial",
|
||||
"SourceTitle": "Título de la fuente",
|
||||
"SourceTitle": "Título de origen",
|
||||
"SpecialEpisode": "Episodio especial",
|
||||
"Specials": "Especiales",
|
||||
"SpecialsFolderFormat": "Formato de carpeta de los especiales",
|
||||
@@ -1573,7 +1572,7 @@
|
||||
"SelectQuality": "Seleccionar calidad",
|
||||
"SelectLanguage": "Seleccionar idioma",
|
||||
"SelectSeason": "Seleccionar temporada",
|
||||
"SeriesIndexFooterMissingUnmonitored": "Episodios perdidos (Serie no monitorizada)",
|
||||
"SeriesIndexFooterMissingUnmonitored": "Episodios faltantes (Serie no monitorizada)",
|
||||
"ShowEpisodeInformation": "Mostrar información de episodio",
|
||||
"ShowPath": "Mostrar ruta",
|
||||
"ShowNetwork": "Mostrar red",
|
||||
@@ -1809,7 +1808,7 @@
|
||||
"RenameEpisodesHelpText": "{appName} usará el nombre de archivo existente si el renombrado está deshabilitado",
|
||||
"RenameEpisodes": "Renombrar episodios",
|
||||
"RestrictionsLoadError": "No se pudo cargar Restricciones",
|
||||
"SearchForMissing": "Buscar perdidos",
|
||||
"SearchForMissing": "Buscar faltantes",
|
||||
"SeasonFinale": "Final de temporada",
|
||||
"SearchSelected": "Buscar seleccionados",
|
||||
"SeasonFolderFormat": "Formato de carpeta de temporada",
|
||||
@@ -1849,7 +1848,6 @@
|
||||
"VersionNumber": "Versión {version}",
|
||||
"OnManualInteractionRequired": "Cuando se requiera interacción manual",
|
||||
"OnLatestVersion": "La última versión de {appName} ya está instalada",
|
||||
"OnUpgrade": "Al actualizar",
|
||||
"RootFolders": "Carpetas raíz",
|
||||
"SeasonPremiere": "Estreno de temporada",
|
||||
"UnableToUpdateSonarrDirectly": "No se pudo actualizar {appName} directamente,",
|
||||
@@ -1992,7 +1990,7 @@
|
||||
"SeriesEditRootFolderHelpText": "Mover series a la misma carpeta raíz se puede usar para renombrar carpetas de series para coincidir el título actualizado o el formato de nombrado",
|
||||
"SeriesFolderFormat": "Formato de carpeta de serie",
|
||||
"SeriesIndexFooterDownloading": "Descargando (Uno o más episodios)",
|
||||
"SeriesIndexFooterMissingMonitored": "Episodios perdidos (Serie monitorizada)",
|
||||
"SeriesIndexFooterMissingMonitored": "Episodios faltantes (Serie monitorizada)",
|
||||
"SeriesProgressBarText": "{episodeFileCount} / {episodeCount} (Total: {totalEpisodeCount}, Descargando: {downloadingCount})",
|
||||
"UpgradesAllowed": "Actualizaciones permitidas",
|
||||
"VideoCodec": "Códec de vídeo",
|
||||
@@ -2040,7 +2038,7 @@
|
||||
"ParseModalErrorParsing": "Error analizando, por favor inténtalo de nuevo.",
|
||||
"ParseModalHelpText": "Introduce un título de lanzamiento en la entrada anterior",
|
||||
"SearchByTvdbId": "También puedes buscar usando la ID de TVDB de un show. P. ej. tvdb:71663",
|
||||
"SearchForAllMissingEpisodesConfirmationCount": "¿Estás seguro que quieres buscar los {totalRecords} episodios perdidos?",
|
||||
"SearchForAllMissingEpisodesConfirmationCount": "¿Estás seguro que quieres buscar los {totalRecords} episodios faltantes?",
|
||||
"SeriesType": "Tipo de serie",
|
||||
"TagCannotBeDeletedWhileInUse": "La etiqueta no puede ser borrada mientras esté en uso",
|
||||
"UnmonitorSpecialEpisodes": "Dejar de monitorizar especiales",
|
||||
@@ -2081,5 +2079,11 @@
|
||||
"DayOfWeekAt": "{day} a las {time}",
|
||||
"UnableToImportAutomatically": "No se pudo importar automáticamente",
|
||||
"NotificationsPlexSettingsServer": "Servidor",
|
||||
"NotificationsPlexSettingsServerHelpText": "Selecciona el servidor desde una cuenta de plex.tv después de autenticarse"
|
||||
"NotificationsPlexSettingsServerHelpText": "Selecciona el servidor desde una cuenta de plex.tv después de autenticarse",
|
||||
"CustomColonReplacement": "Reemplazo personalizado de dos puntos",
|
||||
"CustomColonReplacementFormatHelpText": "Caracteres que serán usados como reemplazo para los dos puntos",
|
||||
"CustomColonReplacementFormatHint": "Caracteres válidos del sistema de archivos como dos puntos (letra)",
|
||||
"OnFileImport": "Al importar un archivo",
|
||||
"OnImportComplete": "Al completar la importación",
|
||||
"OnFileUpgrade": "Al actualizar archivo"
|
||||
}
|
||||
|
||||
@@ -961,7 +961,7 @@
|
||||
"Global": "Yleiset",
|
||||
"GeneralSettings": "Yleiset asetukset",
|
||||
"ImportListsLoadError": "Tuontilistojen lataus epäonnistui",
|
||||
"Importing": "Tuonti",
|
||||
"Importing": "Tuodaan",
|
||||
"IndexerDownloadClientHealthCheckMessage": "Tietolähteet virheellisillä lataustyökaluilla: {indexerNames}.",
|
||||
"Indexer": "Tietolähde",
|
||||
"Location": "Sijainti",
|
||||
@@ -1304,7 +1304,6 @@
|
||||
"Save": "Tallenna",
|
||||
"Seeders": "Jakajat",
|
||||
"UpgradesAllowed": "Päivitykset sallitaan",
|
||||
"OnUpgrade": "Päivitettäessä",
|
||||
"UnmappedFilesOnly": "Vain kohdistamattomat tiedostot",
|
||||
"Ignored": "Ohitettu",
|
||||
"PreferAndUpgrade": "Suosi ja päivitä",
|
||||
|
||||
@@ -946,7 +946,6 @@
|
||||
"MatchedToEpisodes": "Adapté aux épisodes",
|
||||
"NoEpisodesFoundForSelectedSeason": "Aucun épisode n'a été trouvé pour la saison sélectionnée",
|
||||
"OnHealthRestored": "Sur la santé restaurée",
|
||||
"OnImport": "À l'importation",
|
||||
"OnLatestVersion": "La dernière version de {appName} est déjà installée",
|
||||
"PreferAndUpgrade": "Préférer et mettre à niveau",
|
||||
"RejectionCount": "Nombre de rejets",
|
||||
@@ -999,7 +998,6 @@
|
||||
"NoEpisodeInformation": "Aucune information sur l'épisode n'est disponible.",
|
||||
"NoResultsFound": "Aucun résultat trouvé",
|
||||
"NotificationTriggers": "Déclencheurs de notifications",
|
||||
"OnUpgrade": "Lors de la mise à niveau",
|
||||
"Other": "Autre",
|
||||
"OutputPath": "Chemin de sortie",
|
||||
"OverrideGrabNoEpisode": "Au moins un épisode doit être sélectionné",
|
||||
|
||||
@@ -863,5 +863,69 @@
|
||||
"TableColumns": "Colonne",
|
||||
"TodayAt": "Oggi alle {time}",
|
||||
"TomorrowAt": "Domani alle {time}",
|
||||
"TotalSpace": "Totale Spazio"
|
||||
"TotalSpace": "Totale Spazio",
|
||||
"EnableRss": "Abilita RSS",
|
||||
"DownloadFailedEpisodeTooltip": "Download dell'episodio fallito",
|
||||
"Downloading": "Scaricando",
|
||||
"Edit": "Modifica",
|
||||
"EditCustomFormat": "Modifica Formato Personalizzato",
|
||||
"Error": "Errore",
|
||||
"EpisodeDownloaded": "Episodio Scaricato",
|
||||
"ImportListsSonarrSettingsFullUrl": "URL Completo",
|
||||
"ImportListsTraktSettingsLimit": "Limite",
|
||||
"DownloadClient": "Client di Download",
|
||||
"EditConditionImplementation": "Modifica Condizione - {implementationName}",
|
||||
"EpisodeFileMissingTooltip": "File dell'episodio mancante",
|
||||
"EpisodeInfo": "Info Episodio",
|
||||
"DockerUpdater": "Aggiorna il container di docker per ricevere l'aggiornamento",
|
||||
"DownloadFailed": "Download Fallito",
|
||||
"EpisodeFileRenamedTooltip": "Episodio del file rinominato",
|
||||
"EpisodeTitleRequired": "Titolo Episodio Richiesto",
|
||||
"Existing": "Esistente",
|
||||
"Episode": "Episodio",
|
||||
"ImportSeries": "Importa Serie",
|
||||
"Donations": "Donazioni",
|
||||
"EditDelayProfile": "Modifica Profilo di Ritardo",
|
||||
"EnableInteractiveSearchHelpTextWarning": "Ricerca non supportata con questo indicizzatore",
|
||||
"EnableProfile": "Abilita Profilo",
|
||||
"Exception": "Eccezione",
|
||||
"EditGroups": "Modifica Gruppi",
|
||||
"EditDownloadClientImplementation": "Modifica Client di Download - {implementationName}",
|
||||
"EditSelectedSeries": "Modifica Serie Selezionate",
|
||||
"DotNetVersion": ".NET",
|
||||
"DiskSpace": "Spazio sul Disco",
|
||||
"EpisodeFileRenamed": "File dell'Episodio Rinominato",
|
||||
"Events": "Eventi",
|
||||
"Episodes": "Episodi",
|
||||
"NotificationsDiscordSettingsAuthor": "Autore",
|
||||
"EpisodeTitle": "Titolo Episodio",
|
||||
"Imported": "Importato",
|
||||
"EpisodeImported": "Episodio Importato",
|
||||
"EditConnectionImplementation": "Modifica Connessione - {implementationName}",
|
||||
"Enable": "Abilita",
|
||||
"EnableAutomaticAdd": "Abilita Aggiunta Automatica",
|
||||
"EnableAutomaticAddSeriesHelpText": "Aggiungi serie da questa lista a {appName} quando la sincronizzazione è effettuata tramite UI o da {appName}",
|
||||
"EditIndexerImplementation": "Modifica Indicizzatore - {implementationName}",
|
||||
"ImportListsTraktSettingsAuthenticateWithTrakt": "Autentica con Trakt",
|
||||
"ImportListsTraktSettingsGenres": "Generi",
|
||||
"ImportListsTraktSettingsListName": "Nome Lista",
|
||||
"ImportListsTraktSettingsAdditionalParameters": "Parametri Addizionali",
|
||||
"ImportListsTraktSettingsListType": "Tipo Lista",
|
||||
"ImportListsTraktSettingsRating": "Valutazione",
|
||||
"Docker": "Docker",
|
||||
"IndexerHDBitsSettingsCategories": "Categorie",
|
||||
"EditSeries": "Modifica Serie",
|
||||
"Duration": "Durata",
|
||||
"EnableCompletedDownloadHandlingHelpText": "Importa automaticamente i download completati dal client di download",
|
||||
"SeriesIndexFooterEnded": "Terminata (Tutti gli episodi scaricati)",
|
||||
"EpisodeFileDeletedTooltip": "File dell'episodio eliminato",
|
||||
"Downloaded": "Scaricato",
|
||||
"EditQualityProfile": "Modifica Profilo Qualità",
|
||||
"EditSeriesModalHeader": "Modifica - {title}",
|
||||
"EpisodeRequested": "Episodio Richiesto",
|
||||
"DownloadIgnoredEpisodeTooltip": "Download dell'Episodio Ignorato",
|
||||
"EditRestriction": "Modifica Restrizione",
|
||||
"EnableSsl": "Abilita SSL",
|
||||
"EpisodeFileDeleted": "File dell'Episodio Eliminato",
|
||||
"Importing": "Importando"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user