mirror of
https://github.com/Lidarr/Lidarr
synced 2026-05-08 20:59:57 +02:00
Merge pull request #1 from supitsmike/develop-fix
Pull latest develop changes
This commit is contained in:
commit
f10f8ffc51
58 changed files with 1568 additions and 219 deletions
183
CONTRIBUTING.md
183
CONTRIBUTING.md
|
|
@ -1,13 +1,186 @@
|
|||
|
||||
# How to Contribute
|
||||
|
||||
We're always looking for people to help make Lidarr even better, there are a number of ways to contribute.
|
||||
|
||||
This file has been moved to the wiki for the latest details please see the [contributing wiki page](https://wiki.servarr.com/lidarr/contributing).
|
||||
# Documentation
|
||||
|
||||
## Documentation
|
||||
Setup guides, [FAQ](/lidarr/faq), the more information we have on the [wiki](https://wiki.servarr.com/lidarr) the better.
|
||||
|
||||
Setup guides, [FAQ](https://wiki.servarr.com/lidarr/faq), the more information we have on the [wiki](https://wiki.servarr.com/lidarr) the better.
|
||||
# Development
|
||||
|
||||
## Development
|
||||
Lidarr is written in C# (backend) and JS (frontend). The backend is built on the .NET6 (and _soon_ .NET8) framework, while the frontend utilizes Reactjs.
|
||||
|
||||
See the [Wiki Page](https://wiki.servarr.com/lidarr/contributing)
|
||||
## Tools required
|
||||
|
||||
- Visual Studio 2022 or higher is recommended (<https://www.visualstudio.com/vs/>). The community version is free and works (<https://www.visualstudio.com/downloads/>).
|
||||
|
||||
> VS 2022 V17.0 or higher is recommended as it includes the .NET6 SDK
|
||||
{.is-info}
|
||||
|
||||
- HTML/Javascript editor of choice (VS Code/Sublime Text/Webstorm/Atom/etc)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
- The [Node.js](https://nodejs.org/) runtime is required. The following versions are supported:
|
||||
- **20** (any minor or patch version within this)
|
||||
{.grid-list}
|
||||
|
||||
> The Application will **NOT** run on older versions such as `18.x`, `16.x` or any version below 20.0! Due to a dependency issue, it will also not run on `21.x` and is untested on other verisons.
|
||||
{.is-warning}
|
||||
|
||||
- [Yarn](https://yarnpkg.com/getting-started/install) is required to build the frontend
|
||||
- Yarn is included with **Node 20**+ by default. Enable it with `corepack enable`
|
||||
- For other Node versions, install it with `npm i -g corepack`
|
||||
|
||||
## Getting started
|
||||
|
||||
1. Fork Lidarr
|
||||
1. Clone the repository into your development machine. [*info*](https://docs.github.com/en/get-started/quickstart/fork-a-repo)
|
||||
|
||||
> Be sure to run lint `yarn lint --fix` on your code for any front end changes before committing.
|
||||
For css changes `yarn stylelint-windows --fix` {.is-info}
|
||||
|
||||
### Building the frontend
|
||||
|
||||
- Navigate to the cloned directory
|
||||
- Install the required Node Packages
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
```
|
||||
|
||||
- Start webpack to monitor your development environment for any changes that need post processing using:
|
||||
|
||||
```bash
|
||||
yarn start
|
||||
```
|
||||
|
||||
### Building the Backend
|
||||
|
||||
The backend solution is most easily built and ran in Visual Studio or Rider, however if the only priority is working on the frontend UI it can be built easily from command line as well when the correct SDK is installed.
|
||||
|
||||
#### Visual Studio
|
||||
|
||||
> Ensure startup project is set to `Lidarr.Console` and framework to `net6.0`
|
||||
{.is-info}
|
||||
|
||||
1. First `Build` the solution in Visual Studio, this will ensure all projects are correctly built and dependencies restored
|
||||
1. Next `Debug/Run` the project in Visual Studio to start Lidarr
|
||||
1. Open <http://localhost:8686>
|
||||
|
||||
#### Command line
|
||||
|
||||
1. Clean solution
|
||||
|
||||
```shell
|
||||
dotnet clean src/Lidarr.sln -c Debug
|
||||
```
|
||||
|
||||
1. Restore and Build debug configuration for the correct platform (Posix or Windows)
|
||||
|
||||
```shell
|
||||
dotnet msbuild -restore src/Lidarr.sln -p:Configuration=Debug -p:Platform=Posix -t:PublishAllRids
|
||||
```
|
||||
|
||||
1. Run the produced executable from `/_output`
|
||||
|
||||
## Contributing Code
|
||||
|
||||
- If you're adding a new, already requested feature, please comment on [GitHub Issues](https://github.com/Lidarr/Lidarr/issues) so work is not duplicated (If you want to add something not already on there, please talk to us first)
|
||||
- Rebase from Lidarr's develop branch, do not merge
|
||||
- Make meaningful commits, or squash them
|
||||
- Feel free to make a pull request before work is complete, this will let us see where its at and make comments/suggest improvements
|
||||
- Reach out to us on the discord if you have any questions
|
||||
- Add tests (unit/integration)
|
||||
- Commit with \*nix line endings for consistency (We checkout Windows and commit \*nix)
|
||||
- One feature/bug fix per pull request to keep things clean and easy to understand
|
||||
- Use 4 spaces instead of tabs, this is the default for VS 2022 and WebStorm
|
||||
|
||||
## Pull Requesting
|
||||
|
||||
- Only make pull requests to `develop`, never `master`, if you make a PR to `master` we will comment on it and close it
|
||||
- You're probably going to get some comments or questions from us, they will be to ensure consistency and maintainability
|
||||
- We'll try to respond to pull requests as soon as possible, if its been a day or two, please reach out to us, we may have missed it
|
||||
- Each PR should come from its own [feature branch](http://martinfowler.com/bliki/FeatureBranch.html) not develop in your fork, it should have a meaningful branch name (what is being added/fixed)
|
||||
- `new-feature` (Good)
|
||||
- `fix-bug` (Good)
|
||||
- `patch` (Bad)
|
||||
- `develop` (Bad)
|
||||
- Commits should be wrote as `New:` or `Fixed:` for changes that would not be considered a `maintenance release`
|
||||
|
||||
## Unit Testing
|
||||
|
||||
Lidarr utilizes nunit for its unit, integration, and automation test suite.
|
||||
|
||||
### Running Tests
|
||||
|
||||
Tests can be run easily from within VS using the included nunit3testadapter nuget package or from the command line using the included bash script `test.sh`.
|
||||
|
||||
From VS simply navigate to Test Explorer and run or debug the tests you'd like to examine.
|
||||
|
||||
Tests can be run all at once or one at a time in VS.
|
||||
|
||||
From command line the `test.sh` script accepts 3 parameters
|
||||
|
||||
```bash
|
||||
test.sh <PLATFORM> <TYPE> <COVERAGE>
|
||||
```
|
||||
|
||||
### Writing Tests
|
||||
|
||||
While not always fun, we encourage writing unit tests for any backend code changes. This will ensure the change is functioning as you intended and that future changes dont break the expected behavior.
|
||||
|
||||
> We currently require 80% coverage on new code when submitting a PR
|
||||
{.is-info}
|
||||
|
||||
If you have any questions about any of this, please let us know.
|
||||
|
||||
# Translation
|
||||
|
||||
Lidarr uses a self hosted open access [Weblate](https://translate.servarr.com) instance to manage its json translation files. These files are stored in the repo at `src/NzbDrone.Core/Localization`
|
||||
|
||||
## Contributing to an Existing Translation
|
||||
|
||||
Weblate handles synchronization and translation of strings for all languages other than English. Editing of translated strings and translating existing strings for supported languages should be performed there for the Lidarr project.
|
||||
|
||||
The English translation, `en.json`, serves as the source for all other translations and is managed on GitHub repo.
|
||||
|
||||
## Adding a Language
|
||||
|
||||
Adding translations to Lidarr requires two steps
|
||||
|
||||
- Adding the Language to weblate
|
||||
- Adding the Language to Lidarr codebase
|
||||
|
||||
## Adding Translation Strings in Code
|
||||
|
||||
The English translation, `src/NzbDrone.Core/Localization/en.json`, serves as the source for all other translations and is managed on GitHub repo. When adding a new string to either the UI or backend a key must also be added to `en.json` along with the default value in English. This key may then be consumed as follows:
|
||||
|
||||
> PRs for translation of log messages will not be accepted
|
||||
{.is-warning}
|
||||
|
||||
### Backend Strings
|
||||
|
||||
Backend strings may be added utilizing the Localization Service `GetLocalizedString` method
|
||||
|
||||
```dotnet
|
||||
private readonly ILocalizationService _localizationService;
|
||||
|
||||
public IndexerCheck(ILocalizationService localizationService)
|
||||
{
|
||||
_localizationService = localizationService;
|
||||
}
|
||||
|
||||
var translated = _localizationService.GetLocalizedString("IndexerHealthCheckNoIndexers")
|
||||
```
|
||||
|
||||
### Frontend Strings
|
||||
|
||||
New strings can be added to the frontend by importing the translate function and using a key specified from `en.json`
|
||||
|
||||
```js
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
<div>
|
||||
{translate('UnableToAddANewIndexerPleaseTryAgain')}
|
||||
</div>
|
||||
```
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ variables:
|
|||
testsFolder: './_tests'
|
||||
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
|
||||
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
|
||||
majorVersion: '2.14.1'
|
||||
majorVersion: '2.14.2'
|
||||
minorVersion: $[counter('minorVersion', 1076)]
|
||||
lidarrVersion: '$(majorVersion).$(minorVersion)'
|
||||
buildName: '$(Build.SourceBranchName).$(lidarrVersion)'
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
.header {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 310px;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
|
|
@ -30,20 +29,18 @@
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--white);
|
||||
gap: 35px;
|
||||
}
|
||||
|
||||
.cover {
|
||||
flex-shrink: 0;
|
||||
margin-right: 35px;
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
|
|
@ -59,6 +56,8 @@
|
|||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
max-height: calc(2 * 50px);
|
||||
font-weight: 300;
|
||||
font-size: 50px;
|
||||
line-height: 50px;
|
||||
|
|
@ -133,8 +132,12 @@
|
|||
|
||||
.overview {
|
||||
flex: 1 0 auto;
|
||||
overflow-x: auto;
|
||||
margin-top: 8px;
|
||||
min-height: 0;
|
||||
max-height: 150px;
|
||||
text-wrap: balance;
|
||||
white-space: pre-wrap;
|
||||
font-size: $intermediateFontSize;
|
||||
}
|
||||
|
||||
|
|
@ -152,6 +155,7 @@
|
|||
}
|
||||
|
||||
.title {
|
||||
max-height: calc(3 * 30px);
|
||||
font-weight: 300;
|
||||
font-size: 30px;
|
||||
line-height: 30px;
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import Tooltip from 'Components/Tooltip/Tooltip';
|
|||
import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
|
||||
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
|
||||
import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector';
|
||||
import fonts from 'Styles/Variables/fonts';
|
||||
import TrackFileEditorModal from 'TrackFile/Editor/TrackFileEditorModal';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
|
@ -36,9 +35,6 @@ import AlbumDetailsLinks from './AlbumDetailsLinks';
|
|||
import AlbumDetailsMediumConnector from './AlbumDetailsMediumConnector';
|
||||
import styles from './AlbumDetails.css';
|
||||
|
||||
const intermediateFontSize = parseInt(fonts.intermediateFontSize);
|
||||
const lineHeight = parseFloat(fonts.lineHeight);
|
||||
|
||||
function getFanartUrl(images) {
|
||||
return _.find(images, { coverType: 'fanart' })?.url;
|
||||
}
|
||||
|
|
@ -427,7 +423,10 @@ class AlbumDetails extends Component {
|
|||
size={17}
|
||||
/>
|
||||
<span className={styles.releaseDate}>
|
||||
{moment(releaseDate).format(shortDateFormat)}
|
||||
{releaseDate ?
|
||||
moment(releaseDate).format(shortDateFormat) :
|
||||
translate('Unknown')
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
|
|
@ -541,12 +540,8 @@ class AlbumDetails extends Component {
|
|||
/>
|
||||
|
||||
</div>
|
||||
<div className={styles.overview} title={overview}>
|
||||
<TextTruncate
|
||||
line={Math.floor(125 / (intermediateFontSize * lineHeight))}
|
||||
text={overview}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.overview}>{overview}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -592,6 +587,7 @@ class AlbumDetails extends Component {
|
|||
key={medium.mediumNumber}
|
||||
albumId={id}
|
||||
albumMonitored={monitored}
|
||||
albumReleaseDate={releaseDate}
|
||||
{...medium}
|
||||
isExpanded={expandedState[medium.mediumNumber]}
|
||||
onExpandPress={this.onExpandPress}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import Link from 'Components/Link/Link';
|
|||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||
import isAfter from 'Utilities/Date/isAfter';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import TrackRowConnector from './TrackRowConnector';
|
||||
import styles from './AlbumDetailsMedium.css';
|
||||
|
|
@ -31,11 +32,15 @@ function getMediumStatistics(tracks) {
|
|||
};
|
||||
}
|
||||
|
||||
function getTrackCountKind(monitored, trackFileCount, trackCount) {
|
||||
function getTrackCountKind(monitored, releaseDate, trackFileCount, trackCount) {
|
||||
if (trackFileCount === trackCount && trackCount > 0) {
|
||||
return kinds.SUCCESS;
|
||||
}
|
||||
|
||||
if (!releaseDate || isAfter(releaseDate)) {
|
||||
return kinds.DISABLED;
|
||||
}
|
||||
|
||||
if (!monitored) {
|
||||
return kinds.WARNING;
|
||||
}
|
||||
|
|
@ -90,6 +95,7 @@ class AlbumDetailsMedium extends Component {
|
|||
mediumNumber,
|
||||
mediumFormat,
|
||||
albumMonitored,
|
||||
albumReleaseDate,
|
||||
items,
|
||||
columns,
|
||||
onTableOptionChange,
|
||||
|
|
@ -119,7 +125,7 @@ class AlbumDetailsMedium extends Component {
|
|||
|
||||
<Label
|
||||
title={translate('TotalTrackCountTracksTotalTrackFileCountTracksWithFilesInterp', [totalTrackCount, trackFileCount])}
|
||||
kind={getTrackCountKind(albumMonitored, trackFileCount, trackCount)}
|
||||
kind={getTrackCountKind(albumMonitored, albumReleaseDate, trackFileCount, trackCount)}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
{
|
||||
|
|
@ -194,6 +200,7 @@ class AlbumDetailsMedium extends Component {
|
|||
AlbumDetailsMedium.propTypes = {
|
||||
albumId: PropTypes.number.isRequired,
|
||||
albumMonitored: PropTypes.bool.isRequired,
|
||||
albumReleaseDate: PropTypes.string,
|
||||
mediumNumber: PropTypes.number.isRequired,
|
||||
mediumFormat: PropTypes.string.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ class AlbumDetailsMediumConnector extends Component {
|
|||
AlbumDetailsMediumConnector.propTypes = {
|
||||
albumId: PropTypes.number.isRequired,
|
||||
albumMonitored: PropTypes.bool.isRequired,
|
||||
albumReleaseDate: PropTypes.string,
|
||||
mediumNumber: PropTypes.number.isRequired,
|
||||
setTracksTableOption: PropTypes.func.isRequired,
|
||||
executeCommand: PropTypes.func.isRequired
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ class TrackRow extends Component {
|
|||
className={styles.status}
|
||||
>
|
||||
<EpisodeStatusConnector
|
||||
albumId={id}
|
||||
albumId={albumId}
|
||||
trackFileId={trackFileId}
|
||||
/>
|
||||
</TableRowCell>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import styles from './EpisodeStatus.css';
|
|||
|
||||
function EpisodeStatus(props) {
|
||||
const {
|
||||
airDateUtc,
|
||||
releaseDate,
|
||||
monitored,
|
||||
grabbed,
|
||||
queueItem,
|
||||
|
|
@ -20,7 +20,7 @@ function EpisodeStatus(props) {
|
|||
|
||||
const hasTrackFile = !!trackFile;
|
||||
const isQueued = !!queueItem;
|
||||
const hasAired = isBefore(airDateUtc);
|
||||
const isReleased = isBefore(releaseDate);
|
||||
|
||||
if (isQueued) {
|
||||
const {
|
||||
|
|
@ -74,7 +74,7 @@ function EpisodeStatus(props) {
|
|||
);
|
||||
}
|
||||
|
||||
if (!airDateUtc) {
|
||||
if (!releaseDate) {
|
||||
return (
|
||||
<div className={styles.center}>
|
||||
<Icon
|
||||
|
|
@ -96,7 +96,7 @@ function EpisodeStatus(props) {
|
|||
);
|
||||
}
|
||||
|
||||
if (hasAired) {
|
||||
if (isReleased) {
|
||||
return (
|
||||
<div className={styles.center}>
|
||||
<Icon
|
||||
|
|
@ -118,7 +118,7 @@ function EpisodeStatus(props) {
|
|||
}
|
||||
|
||||
EpisodeStatus.propTypes = {
|
||||
airDateUtc: PropTypes.string,
|
||||
releaseDate: PropTypes.string,
|
||||
monitored: PropTypes.bool,
|
||||
grabbed: PropTypes.bool,
|
||||
queueItem: PropTypes.object,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ function createMapStateToProps() {
|
|||
createTrackFileSelector(),
|
||||
(album, queueItem, trackFile) => {
|
||||
const result = _.pick(album, [
|
||||
'airDateUtc',
|
||||
'releaseDate',
|
||||
'monitored',
|
||||
'grabbed'
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -10,15 +10,20 @@ import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
|||
import TableRow from 'Components/Table/TableRow';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
|
||||
import isAfter from 'Utilities/Date/isAfter';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './AlbumRow.css';
|
||||
|
||||
function getTrackCountKind(monitored, trackFileCount, trackCount) {
|
||||
function getTrackCountKind(monitored, releaseDate, trackFileCount, trackCount) {
|
||||
if (trackFileCount === trackCount && trackCount > 0) {
|
||||
return kinds.SUCCESS;
|
||||
}
|
||||
|
||||
if (!releaseDate || isAfter(releaseDate)) {
|
||||
return kinds.DISABLED;
|
||||
}
|
||||
|
||||
if (!monitored) {
|
||||
return kinds.WARNING;
|
||||
}
|
||||
|
|
@ -215,7 +220,7 @@ class AlbumRow extends Component {
|
|||
>
|
||||
<Label
|
||||
title={translate('TotalTrackCountTracksTotalTrackFileCountTracksWithFilesInterp', [totalTrackCount, trackFileCount])}
|
||||
kind={getTrackCountKind(monitored, trackFileCount, trackCount)}
|
||||
kind={getTrackCountKind(monitored, releaseDate, trackFileCount, trackCount)}
|
||||
size={sizes.MEDIUM}
|
||||
>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
.header {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 310px;
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
|
|
@ -36,20 +35,18 @@
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--white);
|
||||
gap: 35px;
|
||||
}
|
||||
|
||||
.poster {
|
||||
flex-shrink: 0;
|
||||
margin-right: 35px;
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.metadataMessage {
|
||||
|
|
@ -72,6 +69,8 @@
|
|||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
max-height: calc(2 * 50px);
|
||||
font-weight: 300;
|
||||
font-size: 50px;
|
||||
line-height: 50px;
|
||||
|
|
@ -144,8 +143,12 @@
|
|||
|
||||
.overview {
|
||||
flex: 1 0 auto;
|
||||
overflow-x: auto;
|
||||
margin-top: 8px;
|
||||
min-height: 0;
|
||||
max-height: 150px;
|
||||
text-wrap: balance;
|
||||
white-space: pre-wrap;
|
||||
font-size: $intermediateFontSize;
|
||||
}
|
||||
|
||||
|
|
@ -163,6 +166,7 @@
|
|||
}
|
||||
|
||||
.title {
|
||||
max-height: calc(3 * 30px);
|
||||
font-weight: 300;
|
||||
font-size: 30px;
|
||||
line-height: 30px;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import TextTruncate from 'react-text-truncate';
|
||||
import ArtistPoster from 'Artist/ArtistPoster';
|
||||
import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
|
||||
import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
|
||||
|
|
@ -28,7 +27,6 @@ import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
|
|||
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
|
||||
import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector';
|
||||
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
|
||||
import fonts from 'Styles/Variables/fonts';
|
||||
import TrackFileEditorModal from 'TrackFile/Editor/TrackFileEditorModal';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
|
@ -42,9 +40,6 @@ import ArtistGenres from './ArtistGenres';
|
|||
import ArtistTagsConnector from './ArtistTagsConnector';
|
||||
import styles from './ArtistDetails.css';
|
||||
|
||||
const defaultFontSize = parseInt(fonts.defaultFontSize);
|
||||
const lineHeight = parseFloat(fonts.lineHeight);
|
||||
|
||||
function getFanartUrl(images) {
|
||||
return _.find(images, { coverType: 'fanart' })?.url;
|
||||
}
|
||||
|
|
@ -394,7 +389,7 @@ class ArtistDetails extends Component {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.title}>
|
||||
<div className={styles.title} title={artistName}>
|
||||
{artistName}
|
||||
</div>
|
||||
|
||||
|
|
@ -595,12 +590,8 @@ class ArtistDetails extends Component {
|
|||
|
||||
}
|
||||
</div>
|
||||
<div className={styles.overview}>
|
||||
<TextTruncate
|
||||
line={Math.floor(125 / (defaultFontSize * lineHeight))}
|
||||
text={overview}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.overview}>{overview}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,14 +5,19 @@ import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellCo
|
|||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import isAfter from 'Utilities/Date/isAfter';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './SelectAlbumRow.css';
|
||||
|
||||
function getTrackCountKind(monitored, trackFileCount, trackCount) {
|
||||
function getTrackCountKind(monitored, releaseDate, trackFileCount, trackCount) {
|
||||
if (trackFileCount === trackCount && trackCount > 0) {
|
||||
return kinds.SUCCESS;
|
||||
}
|
||||
|
||||
if (!releaseDate || isAfter(releaseDate)) {
|
||||
return kinds.DISABLED;
|
||||
}
|
||||
|
||||
if (!monitored) {
|
||||
return kinds.WARNING;
|
||||
}
|
||||
|
|
@ -99,7 +104,7 @@ class SelectAlbumRow extends Component {
|
|||
>
|
||||
<Label
|
||||
title={translate('TotalTrackCountTracksTotalTrackFileCountTracksWithFilesInterp', [totalTrackCount, trackFileCount])}
|
||||
kind={getTrackCountKind(monitored, trackFileCount, trackCount)}
|
||||
kind={getTrackCountKind(monitored, releaseDate, trackFileCount, trackCount)}
|
||||
size={sizes.MEDIUM}
|
||||
>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -124,29 +124,29 @@ class MediaManagement extends Component {
|
|||
<NamingConnector />
|
||||
|
||||
{
|
||||
isFetching &&
|
||||
isFetching ?
|
||||
<FieldSet legend={translate('NamingSettings')}>
|
||||
<LoadingIndicator />
|
||||
</FieldSet>
|
||||
</FieldSet> : null
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && error &&
|
||||
!isFetching && error ?
|
||||
<FieldSet legend={translate('NamingSettings')}>
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('UnableToLoadMediaManagementSettings')}
|
||||
</Alert>
|
||||
</FieldSet>
|
||||
</FieldSet> : null
|
||||
}
|
||||
|
||||
{
|
||||
hasSettings && !isFetching && !error &&
|
||||
hasSettings && !isFetching && !error ?
|
||||
<Form
|
||||
id="mediaManagementSettings"
|
||||
{...otherProps}
|
||||
>
|
||||
{
|
||||
advancedSettings &&
|
||||
advancedSettings ?
|
||||
<FieldSet legend={translate('Folders')}>
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
|
|
@ -183,11 +183,11 @@ class MediaManagement extends Component {
|
|||
{...settings.deleteEmptyFolders}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FieldSet>
|
||||
</FieldSet> : null
|
||||
}
|
||||
|
||||
{
|
||||
advancedSettings &&
|
||||
advancedSettings ?
|
||||
<FieldSet
|
||||
legend={translate('Importing')}
|
||||
>
|
||||
|
|
@ -245,6 +245,41 @@ class MediaManagement extends Component {
|
|||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
size={sizes.MEDIUM}
|
||||
>
|
||||
<FormLabel>{translate('ImportUsingScript')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="useScriptImport"
|
||||
helpText={translate('ImportUsingScriptHelpText')}
|
||||
onChange={onInputChange}
|
||||
{...settings.useScriptImport}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
settings.useScriptImport.value ?
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>{translate('ImportScriptPath')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.PATH}
|
||||
includeFiles={true}
|
||||
name="scriptImportPath"
|
||||
helpText={translate('ImportScriptPathHelpText')}
|
||||
onChange={onInputChange}
|
||||
{...settings.scriptImportPath}
|
||||
/>
|
||||
</FormGroup> : null
|
||||
}
|
||||
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>
|
||||
{translate('ImportExtraFiles')}
|
||||
|
|
@ -279,7 +314,7 @@ class MediaManagement extends Component {
|
|||
/>
|
||||
</FormGroup> : null
|
||||
}
|
||||
</FieldSet>
|
||||
</FieldSet> : null
|
||||
}
|
||||
|
||||
<FieldSet
|
||||
|
|
@ -424,7 +459,7 @@ class MediaManagement extends Component {
|
|||
</FieldSet>
|
||||
|
||||
{
|
||||
advancedSettings && !isWindows &&
|
||||
advancedSettings && !isWindows ?
|
||||
<FieldSet
|
||||
legend={translate('Permissions')}
|
||||
>
|
||||
|
|
@ -483,9 +518,9 @@ class MediaManagement extends Component {
|
|||
{...settings.chownGroup}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FieldSet>
|
||||
</FieldSet> : null
|
||||
}
|
||||
</Form>
|
||||
</Form> : null
|
||||
}
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ public MediaManagementConfigController(IConfigService configService,
|
|||
.When(c => !string.IsNullOrWhiteSpace(c.RecycleBin));
|
||||
SharedValidator.RuleFor(c => c.RecycleBinCleanupDays).GreaterThanOrEqualTo(0);
|
||||
SharedValidator.RuleFor(c => c.ChmodFolder).SetValidator(folderChmodValidator).When(c => !string.IsNullOrEmpty(c.ChmodFolder) && (OsInfo.IsLinux || OsInfo.IsOsx));
|
||||
SharedValidator.RuleFor(c => c.ScriptImportPath).IsValidPath().When(c => c.UseScriptImport);
|
||||
SharedValidator.RuleFor(c => c.MinimumFreeSpaceWhenImporting).GreaterThanOrEqualTo(100);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ public class MediaManagementConfigResource : RestResource
|
|||
public bool SkipFreeSpaceCheckWhenImporting { get; set; }
|
||||
public int MinimumFreeSpaceWhenImporting { get; set; }
|
||||
public bool CopyUsingHardlinks { get; set; }
|
||||
public bool EnableMediaInfo { get; set; }
|
||||
public bool UseScriptImport { get; set; }
|
||||
public string ScriptImportPath { get; set; }
|
||||
public bool ImportExtraFiles { get; set; }
|
||||
public string ExtraFileExtensions { get; set; }
|
||||
}
|
||||
|
|
@ -53,6 +56,9 @@ public static MediaManagementConfigResource ToResource(IConfigService model)
|
|||
SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting,
|
||||
MinimumFreeSpaceWhenImporting = model.MinimumFreeSpaceWhenImporting,
|
||||
CopyUsingHardlinks = model.CopyUsingHardlinks,
|
||||
EnableMediaInfo = model.EnableMediaInfo,
|
||||
UseScriptImport = model.UseScriptImport,
|
||||
ScriptImportPath = model.ScriptImportPath,
|
||||
ImportExtraFiles = model.ImportExtraFiles,
|
||||
ExtraFileExtensions = model.ExtraFileExtensions,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8914,7 +8914,8 @@
|
|||
"ArtistStatusType": {
|
||||
"enum": [
|
||||
"continuing",
|
||||
"ended"
|
||||
"ended",
|
||||
"deleted"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -10870,6 +10871,16 @@
|
|||
"copyUsingHardlinks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"enableMediaInfo": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"useScriptImport": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"scriptImportPath": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"importExtraFiles": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ private static void RegisterGlobalFilters()
|
|||
c.ForLogger("Microsoft.*").WriteToNil(LogLevel.Warn);
|
||||
c.ForLogger("Microsoft.Hosting.Lifetime*").WriteToNil(LogLevel.Info);
|
||||
c.ForLogger("Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware").WriteToNil(LogLevel.Fatal);
|
||||
c.ForLogger("Lidarr.Http.Authentication.ApiKeyAuthenticationHandler").WriteToNil(LogLevel.Info);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
424
src/NzbDrone.Core.Test/MediaFiles/ImportScriptServiceFixture.cs
Normal file
424
src/NzbDrone.Core.Test/MediaFiles/ImportScriptServiceFixture.cs
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using FizzWare.NBuilder;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Common.Processes;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using NzbDrone.Core.CustomFormats;
|
||||
using NzbDrone.Core.Download;
|
||||
using NzbDrone.Core.MediaFiles;
|
||||
using NzbDrone.Core.Music;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Qualities;
|
||||
using NzbDrone.Core.Tags;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
|
||||
namespace NzbDrone.Core.Test.MediaFiles
|
||||
{
|
||||
[TestFixture]
|
||||
public class ImportScriptServiceFixture : CoreTest<ImportScriptService>
|
||||
{
|
||||
private LocalTrack _localTrack;
|
||||
private TrackFile _trackFile;
|
||||
private Artist _artist;
|
||||
private Album _album;
|
||||
private List<Track> _tracks;
|
||||
private Tag _tag;
|
||||
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
_tag = Builder<Tag>.CreateNew()
|
||||
.With(t => t.Id = 1)
|
||||
.With(t => t.Label = "TestTag")
|
||||
.Build();
|
||||
|
||||
_artist = Builder<Artist>.CreateNew()
|
||||
.With(a => a.Id = 1)
|
||||
.With(a => a.Name = "Test Artist")
|
||||
.With(a => a.Path = "/music/Test Artist")
|
||||
.With(a => a.ForeignArtistId = "test-artist-mbid")
|
||||
.With(a => a.Tags = new HashSet<int> { 1 })
|
||||
.Build();
|
||||
|
||||
_album = Builder<Album>.CreateNew()
|
||||
.With(a => a.Id = 1)
|
||||
.With(a => a.Title = "Test Album")
|
||||
.With(a => a.ForeignAlbumId = "test-album-mbid")
|
||||
.With(a => a.ReleaseDate = new System.DateTime(2023, 1, 1))
|
||||
.With(a => a.Genres = new List<string> { "Rock", "Alternative" })
|
||||
.Build();
|
||||
|
||||
_tracks = new List<Track>
|
||||
{
|
||||
Builder<Track>.CreateNew()
|
||||
.With(t => t.Id = 1)
|
||||
.With(t => t.TrackNumber = "1")
|
||||
.With(t => t.Title = "Test Track 1")
|
||||
.Build(),
|
||||
Builder<Track>.CreateNew()
|
||||
.With(t => t.Id = 2)
|
||||
.With(t => t.TrackNumber = "2")
|
||||
.With(t => t.Title = "Test Track 2")
|
||||
.Build()
|
||||
};
|
||||
|
||||
var mediaInfo = Builder<MediaInfoModel>.CreateNew()
|
||||
.With(m => m.AudioChannels = 2)
|
||||
.With(m => m.AudioFormat = "FLAC")
|
||||
.With(m => m.AudioBitrate = 1000)
|
||||
.With(m => m.AudioSampleRate = 44100)
|
||||
.With(m => m.AudioBits = 16)
|
||||
.Build();
|
||||
|
||||
var fileTrackInfo = Builder<ParsedTrackInfo>.CreateNew()
|
||||
.With(p => p.MediaInfo = mediaInfo)
|
||||
.Build();
|
||||
|
||||
_localTrack = Builder<LocalTrack>.CreateNew()
|
||||
.With(l => l.Artist = _artist)
|
||||
.With(l => l.Album = _album)
|
||||
.With(l => l.Tracks = _tracks)
|
||||
.With(l => l.Quality = new QualityModel(Quality.FLAC))
|
||||
.With(l => l.ReleaseGroup = "TestGroup")
|
||||
.With(l => l.SceneName = "Test.Scene.Name")
|
||||
.With(l => l.FileTrackInfo = fileTrackInfo)
|
||||
.Build();
|
||||
|
||||
_trackFile = Builder<TrackFile>.CreateNew()
|
||||
.With(t => t.Path = "/destination/path/track.flac")
|
||||
.Build();
|
||||
|
||||
Mocker.GetMock<IConfigService>()
|
||||
.Setup(s => s.UseScriptImport)
|
||||
.Returns(true);
|
||||
|
||||
Mocker.GetMock<IConfigService>()
|
||||
.Setup(s => s.ScriptImportPath)
|
||||
.Returns("/usr/local/bin/import_script.sh");
|
||||
|
||||
Mocker.GetMock<IConfigService>()
|
||||
.Setup(s => s.ApplicationUrl)
|
||||
.Returns("http://localhost:8686");
|
||||
|
||||
Mocker.GetMock<IConfigFileProvider>()
|
||||
.Setup(s => s.InstanceName)
|
||||
.Returns("Lidarr");
|
||||
|
||||
Mocker.GetMock<ITagRepository>()
|
||||
.Setup(s => s.Get(1))
|
||||
.Returns(_tag);
|
||||
|
||||
var customFormats = Builder<CustomFormat>.CreateListOfSize(2)
|
||||
.TheFirst(1)
|
||||
.With(f => f.Name = "Lossless")
|
||||
.TheNext(1)
|
||||
.With(f => f.Name = "Scene")
|
||||
.Build().ToList();
|
||||
|
||||
Mocker.GetMock<ICustomFormatCalculationService>()
|
||||
.Setup(s => s.ParseCustomFormat(_localTrack))
|
||||
.Returns(customFormats);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_defer_when_script_import_disabled()
|
||||
{
|
||||
// Given
|
||||
Mocker.GetMock<IConfigService>()
|
||||
.Setup(s => s.UseScriptImport)
|
||||
.Returns(false);
|
||||
|
||||
// When
|
||||
var result = Subject.TryImport("/source/path", "/dest/path", _localTrack, _trackFile, TransferMode.Move);
|
||||
|
||||
// Then
|
||||
result.Should().Be(ScriptImportDecision.DeferMove);
|
||||
Mocker.GetMock<IProcessProvider>()
|
||||
.Verify(p => p.StartAndCapture(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<StringDictionary>()), Times.Never);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_call_script_with_correct_arguments()
|
||||
{
|
||||
// Given
|
||||
var processOutput = new ProcessOutput
|
||||
{
|
||||
ExitCode = 0,
|
||||
Lines = new List<ProcessOutputLine> { new ProcessOutputLine(ProcessOutputLevel.Standard, "Script executed successfully") }
|
||||
};
|
||||
|
||||
Mocker.GetMock<IProcessProvider>()
|
||||
.Setup(p => p.StartAndCapture(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<StringDictionary>()))
|
||||
.Returns(processOutput);
|
||||
|
||||
// When
|
||||
var result = Subject.TryImport("/source/path", "/dest/path", _localTrack, _trackFile, TransferMode.Move);
|
||||
|
||||
// Then
|
||||
Mocker.GetMock<IProcessProvider>()
|
||||
.Verify(p => p.StartAndCapture(
|
||||
"/usr/local/bin/import_script.sh",
|
||||
"\"/source/path\" \"/dest/path\"",
|
||||
It.IsAny<StringDictionary>()),
|
||||
Times.Once);
|
||||
|
||||
result.Should().Be(ScriptImportDecision.MoveComplete);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_pass_correct_environment_variables()
|
||||
{
|
||||
// Given
|
||||
var processOutput = new ProcessOutput
|
||||
{
|
||||
ExitCode = 3,
|
||||
Lines = new List<ProcessOutputLine>()
|
||||
};
|
||||
|
||||
StringDictionary capturedEnv = null;
|
||||
Mocker.GetMock<IProcessProvider>()
|
||||
.Setup(p => p.StartAndCapture(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<StringDictionary>()))
|
||||
.Callback<string, string, StringDictionary>((script, args, env) => capturedEnv = env)
|
||||
.Returns(processOutput);
|
||||
|
||||
// When
|
||||
Subject.TryImport("/source/path", "/dest/path", _localTrack, _trackFile, TransferMode.Copy);
|
||||
|
||||
// Then
|
||||
capturedEnv.Should().NotBeNull();
|
||||
|
||||
// Basic paths and instance info
|
||||
capturedEnv["Lidarr_SourcePath"].Should().Be("/source/path");
|
||||
capturedEnv["Lidarr_DestinationPath"].Should().Be("/dest/path");
|
||||
capturedEnv["Lidarr_InstanceName"].Should().Be("Lidarr");
|
||||
capturedEnv["Lidarr_ApplicationUrl"].Should().Be("http://localhost:8686");
|
||||
capturedEnv["Lidarr_TransferMode"].Should().Be("Copy");
|
||||
|
||||
// Artist info
|
||||
capturedEnv["Lidarr_Artist_Id"].Should().Be("1");
|
||||
capturedEnv["Lidarr_Artist_Name"].Should().Be("Test Artist");
|
||||
capturedEnv["Lidarr_Artist_Path"].Should().Be("/music/Test Artist");
|
||||
capturedEnv["Lidarr_Artist_MBId"].Should().Be("test-artist-mbid");
|
||||
capturedEnv["Lidarr_Artist_Tags"].Should().Be("TestTag");
|
||||
|
||||
// Album info
|
||||
capturedEnv["Lidarr_Album_Id"].Should().Be("1");
|
||||
capturedEnv["Lidarr_Album_Title"].Should().Be("Test Album");
|
||||
capturedEnv["Lidarr_Album_MBId"].Should().Be("test-album-mbid");
|
||||
capturedEnv["Lidarr_Album_ReleaseDate"].Should().Be("2023-01-01");
|
||||
capturedEnv["Lidarr_Album_Genres"].Should().Be("Rock|Alternative");
|
||||
|
||||
// Track info
|
||||
capturedEnv["Lidarr_TrackFile_TrackCount"].Should().Be("2");
|
||||
capturedEnv["Lidarr_TrackFile_TrackIds"].Should().Be("1,2");
|
||||
capturedEnv["Lidarr_TrackFile_TrackNumbers"].Should().Be("1,2");
|
||||
capturedEnv["Lidarr_TrackFile_TrackTitles"].Should().Be("Test Track 1|Test Track 2");
|
||||
capturedEnv["Lidarr_TrackFile_Quality"].Should().Be("FLAC");
|
||||
capturedEnv["Lidarr_TrackFile_ReleaseGroup"].Should().Be("TestGroup");
|
||||
capturedEnv["Lidarr_TrackFile_SceneName"].Should().Be("Test.Scene.Name");
|
||||
|
||||
// Media info
|
||||
capturedEnv["Lidarr_TrackFile_MediaInfo_AudioChannels"].Should().Be("2");
|
||||
capturedEnv["Lidarr_TrackFile_MediaInfo_AudioCodec"].Should().Be("FLAC");
|
||||
capturedEnv["Lidarr_TrackFile_MediaInfo_AudioBitRate"].Should().Be("1000");
|
||||
capturedEnv["Lidarr_TrackFile_MediaInfo_AudioSampleRate"].Should().Be("44100");
|
||||
capturedEnv["Lidarr_TrackFile_MediaInfo_BitsPerSample"].Should().Be("16");
|
||||
|
||||
// Custom formats
|
||||
capturedEnv["Lidarr_TrackFile_CustomFormat"].Should().Be("Lossless|Scene");
|
||||
|
||||
// Download client info (should be empty when not provided)
|
||||
capturedEnv["Lidarr_Download_Client"].Should().Be("");
|
||||
capturedEnv["Lidarr_Download_Client_Type"].Should().Be("");
|
||||
capturedEnv["Lidarr_Download_Id"].Should().Be("");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_include_download_client_info_when_provided()
|
||||
{
|
||||
// Given
|
||||
var downloadClientInfo = Builder<DownloadClientItemClientInfo>.CreateNew()
|
||||
.With(d => d.Name = "qBittorrent")
|
||||
.With(d => d.Type = "Torrent")
|
||||
.Build();
|
||||
|
||||
var downloadClientItem = Builder<DownloadClientItem>.CreateNew()
|
||||
.With(d => d.DownloadClientInfo = downloadClientInfo)
|
||||
.With(d => d.DownloadId = "test-download-id")
|
||||
.Build();
|
||||
|
||||
var processOutput = new ProcessOutput
|
||||
{
|
||||
ExitCode = 3,
|
||||
Lines = new List<ProcessOutputLine>()
|
||||
};
|
||||
|
||||
StringDictionary capturedEnv = null;
|
||||
Mocker.GetMock<IProcessProvider>()
|
||||
.Setup(p => p.StartAndCapture(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<StringDictionary>()))
|
||||
.Callback<string, string, StringDictionary>((script, args, env) => capturedEnv = env)
|
||||
.Returns(processOutput);
|
||||
|
||||
// When
|
||||
Subject.TryImport("/source/path", "/dest/path", _localTrack, _trackFile, TransferMode.Move, downloadClientItem);
|
||||
|
||||
// Then
|
||||
capturedEnv["Lidarr_Download_Client"].Should().Be("qBittorrent");
|
||||
capturedEnv["Lidarr_Download_Client_Type"].Should().Be("Torrent");
|
||||
capturedEnv["Lidarr_Download_Id"].Should().Be("test-download-id");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_move_complete_when_script_returns_0()
|
||||
{
|
||||
// Given
|
||||
var processOutput = new ProcessOutput
|
||||
{
|
||||
ExitCode = 0,
|
||||
Lines = new List<ProcessOutputLine>()
|
||||
};
|
||||
|
||||
Mocker.GetMock<IProcessProvider>()
|
||||
.Setup(p => p.StartAndCapture(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<StringDictionary>()))
|
||||
.Returns(processOutput);
|
||||
|
||||
// When
|
||||
var result = Subject.TryImport("/source/path", "/dest/path", _localTrack, _trackFile, TransferMode.Move);
|
||||
|
||||
// Then
|
||||
result.Should().Be(ScriptImportDecision.MoveComplete);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_rename_requested_when_script_returns_2()
|
||||
{
|
||||
// Given
|
||||
var processOutput = new ProcessOutput
|
||||
{
|
||||
ExitCode = 2,
|
||||
Lines = new List<ProcessOutputLine>()
|
||||
};
|
||||
|
||||
var audioTag = Builder<AudioTag>.CreateNew()
|
||||
.With(a => a.MediaInfo = new MediaInfoModel())
|
||||
.Build();
|
||||
|
||||
Mocker.GetMock<IProcessProvider>()
|
||||
.Setup(p => p.StartAndCapture(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<StringDictionary>()))
|
||||
.Returns(processOutput);
|
||||
|
||||
Mocker.GetMock<IAudioTagService>()
|
||||
.Setup(s => s.ReadTags("/dest/path"))
|
||||
.Returns(audioTag);
|
||||
|
||||
// When
|
||||
var result = Subject.TryImport("/source/path", "/dest/path", _localTrack, _trackFile, TransferMode.Move);
|
||||
|
||||
// Then
|
||||
result.Should().Be(ScriptImportDecision.RenameRequested);
|
||||
_trackFile.MediaInfo.Should().Be(audioTag.MediaInfo);
|
||||
_trackFile.Path.Should().BeNull();
|
||||
|
||||
Mocker.GetMock<IAudioTagService>()
|
||||
.Verify(s => s.ReadTags("/dest/path"), Times.Once);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_defer_move_when_script_returns_3()
|
||||
{
|
||||
// Given
|
||||
var processOutput = new ProcessOutput
|
||||
{
|
||||
ExitCode = 3,
|
||||
Lines = new List<ProcessOutputLine>()
|
||||
};
|
||||
|
||||
Mocker.GetMock<IProcessProvider>()
|
||||
.Setup(p => p.StartAndCapture(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<StringDictionary>()))
|
||||
.Returns(processOutput);
|
||||
|
||||
// When
|
||||
var result = Subject.TryImport("/source/path", "/dest/path", _localTrack, _trackFile, TransferMode.Move);
|
||||
|
||||
// Then
|
||||
result.Should().Be(ScriptImportDecision.DeferMove);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_throw_exception_when_script_returns_error_code()
|
||||
{
|
||||
// Given
|
||||
var processOutput = new ProcessOutput
|
||||
{
|
||||
ExitCode = 1,
|
||||
Lines = new List<ProcessOutputLine> { new ProcessOutputLine(ProcessOutputLevel.Error, "Error message from script") }
|
||||
};
|
||||
|
||||
Mocker.GetMock<IProcessProvider>()
|
||||
.Setup(p => p.StartAndCapture(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<StringDictionary>()))
|
||||
.Returns(processOutput);
|
||||
|
||||
// When & Then
|
||||
Assert.Throws<ScriptImportException>(() =>
|
||||
Subject.TryImport("/source/path", "/dest/path", _localTrack, _trackFile, TransferMode.Move));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_handle_missing_media_info_gracefully()
|
||||
{
|
||||
// Given
|
||||
_localTrack.FileTrackInfo.MediaInfo = null;
|
||||
|
||||
var processOutput = new ProcessOutput
|
||||
{
|
||||
ExitCode = 3,
|
||||
Lines = new List<ProcessOutputLine>()
|
||||
};
|
||||
|
||||
StringDictionary capturedEnv = null;
|
||||
Mocker.GetMock<IProcessProvider>()
|
||||
.Setup(p => p.StartAndCapture(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<StringDictionary>()))
|
||||
.Callback<string, string, StringDictionary>((script, args, env) => capturedEnv = env)
|
||||
.Returns(processOutput);
|
||||
|
||||
// When
|
||||
Subject.TryImport("/source/path", "/dest/path", _localTrack, _trackFile, TransferMode.Move);
|
||||
|
||||
// Then
|
||||
capturedEnv.Should().NotBeNull();
|
||||
capturedEnv.ContainsKey("Lidarr_TrackFile_MediaInfo_AudioChannels").Should().BeFalse();
|
||||
capturedEnv.ContainsKey("Lidarr_TrackFile_MediaInfo_AudioCodec").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_handle_missing_file_track_info_gracefully()
|
||||
{
|
||||
// Given
|
||||
_localTrack.FileTrackInfo = null;
|
||||
|
||||
var processOutput = new ProcessOutput
|
||||
{
|
||||
ExitCode = 3,
|
||||
Lines = new List<ProcessOutputLine>()
|
||||
};
|
||||
|
||||
StringDictionary capturedEnv = null;
|
||||
Mocker.GetMock<IProcessProvider>()
|
||||
.Setup(p => p.StartAndCapture(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<StringDictionary>()))
|
||||
.Callback<string, string, StringDictionary>((script, args, env) => capturedEnv = env)
|
||||
.Returns(processOutput);
|
||||
|
||||
// When
|
||||
var result = Subject.TryImport("/source/path", "/dest/path", _localTrack, _trackFile, TransferMode.Move);
|
||||
|
||||
// Then
|
||||
result.Should().Be(ScriptImportDecision.DeferMove);
|
||||
capturedEnv.ContainsKey("Lidarr_TrackFile_MediaInfo_AudioChannels").Should().BeFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -163,5 +163,29 @@ public void test_raw_distance()
|
|||
|
||||
dist.RawDistance().Should().Be(2.25);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void test_add_string_null_handling()
|
||||
{
|
||||
var dist = new Distance();
|
||||
|
||||
dist.AddString("string", null, "target");
|
||||
dist.Penalties.Should().BeEquivalentTo(new Dictionary<string, List<double>> { { "string", new List<double> { 1.0 } } });
|
||||
|
||||
dist.AddString("string2", "value", null);
|
||||
dist.Penalties.Should().BeEquivalentTo(new Dictionary<string, List<double>>
|
||||
{
|
||||
{ "string", new List<double> { 1.0 } },
|
||||
{ "string2", new List<double> { 1.0 } }
|
||||
});
|
||||
|
||||
dist.AddString("string3", null, null);
|
||||
dist.Penalties.Should().BeEquivalentTo(new Dictionary<string, List<double>>
|
||||
{
|
||||
{ "string", new List<double> { 1.0 } },
|
||||
{ "string2", new List<double> { 1.0 } },
|
||||
{ "string3", new List<double> { 0.0 } }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
namespace NzbDrone.Core.Test.MetadataSource.SkyHook
|
||||
{
|
||||
[TestFixture]
|
||||
[Ignore("Waiting for metadata to be back again", Until = "2025-09-01 00:00:00Z")]
|
||||
[Ignore("Waiting for metadata to be back again", Until = "2025-10-01 00:00:00Z")]
|
||||
public class SkyHookProxyFixture : CoreTest<SkyHookProxy>
|
||||
{
|
||||
private MetadataProfile _metadataProfile;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
namespace NzbDrone.Core.Test.MetadataSource.SkyHook
|
||||
{
|
||||
[TestFixture]
|
||||
[Ignore("Waiting for metadata to be back again", Until = "2025-09-01 00:00:00Z")]
|
||||
[Ignore("Waiting for metadata to be back again", Until = "2025-10-01 00:00:00Z")]
|
||||
public class SkyHookProxySearchFixture : CoreTest<SkyHookProxy>
|
||||
{
|
||||
[SetUp]
|
||||
|
|
|
|||
|
|
@ -21,14 +21,17 @@ public void Setup()
|
|||
.All()
|
||||
.With(e => e.Monitored = true)
|
||||
.With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(-7))
|
||||
.With(e => e.Title = "Test Album")
|
||||
|
||||
// Future
|
||||
.TheFirst(1)
|
||||
.With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(7))
|
||||
.With(e => e.Title = "Future Album")
|
||||
|
||||
// Future/TBA
|
||||
.TheNext(1)
|
||||
.With(e => e.ReleaseDate = null)
|
||||
.With(e => e.Title = "TBA Album")
|
||||
.Build()
|
||||
.ToList();
|
||||
}
|
||||
|
|
@ -61,5 +64,148 @@ public void should_only_monitor_new_with_new()
|
|||
Subject.ShouldMonitorNewAlbum(album, _albums, NewItemMonitorTypes.New).Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_monitor_album_with_null_release_date()
|
||||
{
|
||||
var albumWithNullDate = Builder<Album>.CreateNew()
|
||||
.With(e => e.ReleaseDate = null)
|
||||
.With(e => e.Title = "No Date Album")
|
||||
.Build();
|
||||
|
||||
var existingAlbums = Builder<Album>.CreateListOfSize(2)
|
||||
.All()
|
||||
.With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(-30))
|
||||
.Build()
|
||||
.ToList();
|
||||
|
||||
Subject.ShouldMonitorNewAlbum(albumWithNullDate, existingAlbums, NewItemMonitorTypes.New)
|
||||
.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_monitor_album_when_no_existing_albums_have_dates()
|
||||
{
|
||||
var newAlbumWithDate = Builder<Album>.CreateNew()
|
||||
.With(e => e.ReleaseDate = DateTime.UtcNow)
|
||||
.With(e => e.Title = "New Album With Date")
|
||||
.Build();
|
||||
|
||||
var existingAlbumsWithoutDates = Builder<Album>.CreateListOfSize(3)
|
||||
.All()
|
||||
.With(e => e.ReleaseDate = null)
|
||||
.Build()
|
||||
.ToList();
|
||||
|
||||
Subject.ShouldMonitorNewAlbum(newAlbumWithDate, existingAlbumsWithoutDates, NewItemMonitorTypes.New)
|
||||
.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_monitor_album_newer_than_existing_albums()
|
||||
{
|
||||
var newerAlbum = Builder<Album>.CreateNew()
|
||||
.With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(1))
|
||||
.With(e => e.Title = "Newer Album")
|
||||
.Build();
|
||||
|
||||
var existingAlbums = Builder<Album>.CreateListOfSize(3)
|
||||
.All()
|
||||
.With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(-30))
|
||||
.TheFirst(1)
|
||||
.With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(-1)) // Most recent existing
|
||||
.Build()
|
||||
.ToList();
|
||||
|
||||
Subject.ShouldMonitorNewAlbum(newerAlbum, existingAlbums, NewItemMonitorTypes.New)
|
||||
.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_monitor_album_older_than_existing_albums()
|
||||
{
|
||||
var olderAlbum = Builder<Album>.CreateNew()
|
||||
.With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(-10))
|
||||
.With(e => e.Title = "Older Album")
|
||||
.Build();
|
||||
|
||||
var existingAlbums = Builder<Album>.CreateListOfSize(3)
|
||||
.All()
|
||||
.With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(-30))
|
||||
.TheFirst(1)
|
||||
.With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(-1)) // Most recent existing
|
||||
.Build()
|
||||
.ToList();
|
||||
|
||||
Subject.ShouldMonitorNewAlbum(olderAlbum, existingAlbums, NewItemMonitorTypes.New)
|
||||
.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_monitor_album_with_same_date_as_existing_album()
|
||||
{
|
||||
var sameDate = DateTime.UtcNow.AddDays(-5);
|
||||
var albumWithSameDate = Builder<Album>.CreateNew()
|
||||
.With(e => e.ReleaseDate = sameDate)
|
||||
.With(e => e.Title = "Same Date Album")
|
||||
.Build();
|
||||
|
||||
var existingAlbums = Builder<Album>.CreateListOfSize(3)
|
||||
.All()
|
||||
.With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(-30))
|
||||
.TheFirst(1)
|
||||
.With(e => e.ReleaseDate = sameDate) // Same date as new album
|
||||
.Build()
|
||||
.ToList();
|
||||
|
||||
Subject.ShouldMonitorNewAlbum(albumWithSameDate, existingAlbums, NewItemMonitorTypes.New)
|
||||
.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_ignore_existing_albums_with_null_dates_when_finding_newest()
|
||||
{
|
||||
var newAlbum = Builder<Album>.CreateNew()
|
||||
.With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(1))
|
||||
.With(e => e.Title = "New Album")
|
||||
.Build();
|
||||
|
||||
var existingAlbums = Builder<Album>.CreateListOfSize(4)
|
||||
.All()
|
||||
.With(e => e.ReleaseDate = null) // All null dates
|
||||
.TheFirst(1)
|
||||
.With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(-5)) // Only one with actual date
|
||||
.Build()
|
||||
.ToList();
|
||||
|
||||
Subject.ShouldMonitorNewAlbum(newAlbum, existingAlbums, NewItemMonitorTypes.New)
|
||||
.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_throw_for_unknown_monitor_type()
|
||||
{
|
||||
var album = _albums.First();
|
||||
Assert.Throws<NotImplementedException>(() =>
|
||||
Subject.ShouldMonitorNewAlbum(album, _albums, (NewItemMonitorTypes)999));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_monitor_album_with_null_date_when_all_existing_albums_also_have_null_dates()
|
||||
{
|
||||
var albumWithNullDate = Builder<Album>.CreateNew()
|
||||
.With(e => e.ReleaseDate = null)
|
||||
.With(e => e.Title = "No Date Album")
|
||||
.Build();
|
||||
|
||||
var existingAlbumsWithoutDates = Builder<Album>.CreateListOfSize(3)
|
||||
.All()
|
||||
.With(e => e.ReleaseDate = null)
|
||||
.Build()
|
||||
.ToList();
|
||||
|
||||
Subject.ShouldMonitorNewAlbum(albumWithNullDate, existingAlbumsWithoutDates, NewItemMonitorTypes.New)
|
||||
.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,11 @@ private void GivenArtistIsEnded()
|
|||
_artist.Metadata.Value.Status = ArtistStatusType.Ended;
|
||||
}
|
||||
|
||||
private void GivenArtistIsDeleted()
|
||||
{
|
||||
_artist.Metadata.Value.Status = ArtistStatusType.Deleted;
|
||||
}
|
||||
|
||||
private void GivenArtistLastRefreshedMonthsAgo()
|
||||
{
|
||||
_artist.LastInfoSync = DateTime.UtcNow.AddDays(-90);
|
||||
|
|
@ -113,7 +118,7 @@ public void should_return_true_if_album_released_in_last_30_days()
|
|||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_false_when_recently_refreshed_ended_show_has_not_aired_for_30_days()
|
||||
public void should_return_false_when_recently_refreshed_ended_artist_has_not_released_for_30_days()
|
||||
{
|
||||
GivenArtistIsEnded();
|
||||
GivenArtistLastRefreshedYesterday();
|
||||
|
|
@ -122,7 +127,7 @@ public void should_return_false_when_recently_refreshed_ended_show_has_not_aired
|
|||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_false_when_recently_refreshed_ended_show_aired_in_last_30_days()
|
||||
public void should_return_false_when_recently_refreshed_ended_artist_released_in_last_30_days()
|
||||
{
|
||||
GivenArtistIsEnded();
|
||||
GivenArtistLastRefreshedRecently();
|
||||
|
|
@ -131,5 +136,14 @@ public void should_return_false_when_recently_refreshed_ended_show_aired_in_last
|
|||
|
||||
Subject.ShouldRefresh(_artist).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_true_if_deleted_artist_last_refreshed_more_than_2_days_ago()
|
||||
{
|
||||
GivenArtistLastRefreshedThreeDaysAgo();
|
||||
GivenArtistIsDeleted();
|
||||
|
||||
Subject.ShouldRefresh(_artist).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -207,6 +207,27 @@ public bool CopyUsingHardlinks
|
|||
set { SetValue("CopyUsingHardlinks", value); }
|
||||
}
|
||||
|
||||
public bool EnableMediaInfo
|
||||
{
|
||||
get { return GetValueBoolean("EnableMediaInfo", true); }
|
||||
|
||||
set { SetValue("EnableMediaInfo", value); }
|
||||
}
|
||||
|
||||
public bool UseScriptImport
|
||||
{
|
||||
get { return GetValueBoolean("UseScriptImport", false); }
|
||||
|
||||
set { SetValue("UseScriptImport", value); }
|
||||
}
|
||||
|
||||
public string ScriptImportPath
|
||||
{
|
||||
get { return GetValue("ScriptImportPath"); }
|
||||
|
||||
set { SetValue("ScriptImportPath", value); }
|
||||
}
|
||||
|
||||
public bool ImportExtraFiles
|
||||
{
|
||||
get { return GetValueBoolean("ImportExtraFiles", false); }
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ public interface IConfigService
|
|||
bool SkipFreeSpaceCheckWhenImporting { get; set; }
|
||||
int MinimumFreeSpaceWhenImporting { get; set; }
|
||||
bool CopyUsingHardlinks { get; set; }
|
||||
bool EnableMediaInfo { get; set; }
|
||||
bool UseScriptImport { get; set; }
|
||||
string ScriptImportPath { get; set; }
|
||||
bool ImportExtraFiles { get; set; }
|
||||
string ExtraFileExtensions { get; set; }
|
||||
bool WatchLibraryForChanges { get; set; }
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using NLog;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.IndexerSearch.Definitions;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
|
|
@ -6,9 +7,15 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
|||
{
|
||||
public class CustomFormatAllowedbyProfileSpecification : IDecisionEngineSpecification
|
||||
{
|
||||
private readonly Logger _logger;
|
||||
public SpecificationPriority Priority => SpecificationPriority.Default;
|
||||
public RejectionType Type => RejectionType.Permanent;
|
||||
|
||||
public CustomFormatAllowedbyProfileSpecification(Logger logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public virtual Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria)
|
||||
{
|
||||
var minScore = subject.Artist.QualityProfile.Value.MinFormatScore;
|
||||
|
|
@ -19,6 +26,8 @@ public virtual Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase se
|
|||
return Decision.Reject("Custom Formats {0} have score {1} below Artist profile minimum {2}", subject.CustomFormats.ConcatToString(), score, minScore);
|
||||
}
|
||||
|
||||
_logger.Trace("Custom Format Score of {0} [{1}] above Artist profile minimum {2}", score, subject.CustomFormats.ConcatToString(), minScore);
|
||||
|
||||
return Decision.Accept();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
53
src/NzbDrone.Core/HealthCheck/Checks/RemovedArtistCheck.cs
Normal file
53
src/NzbDrone.Core/HealthCheck/Checks/RemovedArtistCheck.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
using System.Linq;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.Localization;
|
||||
using NzbDrone.Core.Music;
|
||||
using NzbDrone.Core.Music.Events;
|
||||
|
||||
namespace NzbDrone.Core.HealthCheck.Checks
|
||||
{
|
||||
[CheckOn(typeof(ArtistUpdatedEvent))]
|
||||
[CheckOn(typeof(ArtistsDeletedEvent), CheckOnCondition.FailedOnly)]
|
||||
public class RemovedArtistCheck : HealthCheckBase, ICheckOnCondition<ArtistUpdatedEvent>, ICheckOnCondition<ArtistsDeletedEvent>
|
||||
{
|
||||
private readonly IArtistService _artistService;
|
||||
private readonly Logger _logger;
|
||||
|
||||
public RemovedArtistCheck(ILocalizationService localizationService, IArtistService artistService, Logger logger)
|
||||
: base(localizationService)
|
||||
{
|
||||
_artistService = artistService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public override HealthCheck Check()
|
||||
{
|
||||
var deletedArtists = _artistService.GetAllArtists().Where(v => v.Metadata.Value.Status == ArtistStatusType.Deleted).ToList();
|
||||
|
||||
if (deletedArtists.Empty())
|
||||
{
|
||||
return new HealthCheck(GetType());
|
||||
}
|
||||
|
||||
var artistText = deletedArtists.Select(s => $"{s.Name} (mbid {s.ForeignArtistId})").Join(", ");
|
||||
|
||||
if (deletedArtists.Count == 1)
|
||||
{
|
||||
return new HealthCheck(GetType(), HealthCheckResult.Error, $"Artist {artistText} was removed from MusicBrainz");
|
||||
}
|
||||
|
||||
return new HealthCheck(GetType(), HealthCheckResult.Error, $"Artists {artistText} were removed from MusicBrainz");
|
||||
}
|
||||
|
||||
public bool ShouldCheckOnEvent(ArtistsDeletedEvent deletedEvent)
|
||||
{
|
||||
return deletedEvent.Artists.Any(artist => artist.Metadata.Value.Status == ArtistStatusType.Deleted);
|
||||
}
|
||||
|
||||
public bool ShouldCheckOnEvent(ArtistUpdatedEvent updatedEvent)
|
||||
{
|
||||
return updatedEvent.Artist.Metadata.Value.Status == ArtistStatusType.Deleted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -136,7 +136,7 @@ private void ProcessHealthChecks()
|
|||
|
||||
public void Execute(CheckHealthCommand message)
|
||||
{
|
||||
var healthChecks = message.Trigger == CommandTrigger.Manual ? _healthChecks : _scheduledHealthChecks;
|
||||
var healthChecks = message.Trigger == CommandTrigger.Manual ? _healthChecks : _scheduledHealthChecks;
|
||||
|
||||
lock (_pendingHealthChecks)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<PackageReference Include="System.Text.Json" Version="6.0.10" />
|
||||
<PackageReference Include="System.Memory" Version="4.6.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Cryptography.KeyDerivation" Version="6.0.35" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="2.1.7" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.1" />
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
"Calendar": "Kalendář",
|
||||
"CalendarWeekColumnHeaderHelpText": "Zobrazuje se nad každým sloupcem, když je aktivní zobrazení týden",
|
||||
"Cancel": "Zrušit",
|
||||
"CancelPendingTask": "Opravdu chcete zrušit tento úkol čekající na vyřízení?",
|
||||
"CancelPendingTask": "Opravdu chcete zrušit tento čekající úkol?",
|
||||
"CertificateValidation": "Ověřování certifikátu",
|
||||
"CertificateValidationHelpText": "Změňte přísnost ověřování certifikátů HTTPS. Neměňte, pokud nerozumíte rizikům.",
|
||||
"ChangeFileDate": "Změnit datum souboru",
|
||||
|
|
@ -284,7 +284,7 @@
|
|||
"Unmonitored": "Nemonitorováno",
|
||||
"UnmonitoredHelpText": "Zahrnout nemonitorované filmy do zdroje iCal",
|
||||
"UpdateAutomaticallyHelpText": "Automaticky stahovat a instalovat aktualizace. Stále budete moci instalovat ze systému: Aktualizace",
|
||||
"UpdateMechanismHelpText": "Použijte vestavěný aktualizátor {appName} nebo skript",
|
||||
"UpdateMechanismHelpText": "Použijte vestavěný nástroj {appName}u pro aktualizaci nebo skript",
|
||||
"Updates": "Aktualizace",
|
||||
"UpdateScriptPathHelpText": "Cesta k vlastnímu skriptu, který přebírá extrahovaný balíček aktualizace a zpracovává zbytek procesu aktualizace",
|
||||
"UpgradeAllowedHelpText": "Pokud budou deaktivovány vlastnosti, nebudou upgradovány",
|
||||
|
|
@ -476,7 +476,7 @@
|
|||
"Custom": "Vlastní",
|
||||
"CustomFilters": "Vlastní filtry",
|
||||
"Date": "Datum",
|
||||
"DoNotPrefer": "Nepřednostňovat",
|
||||
"DoNotPrefer": "Neupřednostňovat",
|
||||
"DoNotUpgradeAutomatically": "Neupgradovat automaticky",
|
||||
"DownloadFailed": "Stažení se nezdařilo",
|
||||
"EditDelayProfile": "Upravit profil zpoždění",
|
||||
|
|
@ -529,7 +529,7 @@
|
|||
"Apply": "Použít",
|
||||
"AudioInfo": "Audio informace",
|
||||
"Deleted": "Smazáno",
|
||||
"Details": "Detaily",
|
||||
"Details": "Podrobnosti",
|
||||
"Donations": "Dary",
|
||||
"ErrorRestoringBackup": "Chyba při obnovování zálohy",
|
||||
"Filters": "Filtr",
|
||||
|
|
@ -557,14 +557,14 @@
|
|||
"CloneCustomFormat": "Klonovat vlastní formát",
|
||||
"Conditions": "Podmínky",
|
||||
"CopyToClipboard": "Zkopírovat do schránky",
|
||||
"CouldntFindAnyResultsForTerm": "Nelze najít žádné výsledky pro dotaz „{0}“",
|
||||
"CouldntFindAnyResultsForTerm": "Nelze najít žádné výsledky pro „{0}“",
|
||||
"CustomFormat": "Vlastní formát",
|
||||
"CustomFormatRequiredHelpText": "Tato {0} podmínka musí odpovídat, aby se aplikoval vlastní formát. Jinak stačí jedna shoda {0}.",
|
||||
"CustomFormatSettings": "Nastavení vlastních formátů",
|
||||
"CustomFormatSettings": "Nastavení vlastního formátu",
|
||||
"CustomFormats": "Vlastní formáty",
|
||||
"Customformat": "Vlastní formát",
|
||||
"CutoffFormatScoreHelpText": "Jakmile je dosaženo tohoto skóre vlastního formátu, {appName} již nebude stahovat filmy",
|
||||
"DeleteCustomFormat": "Odstranit vlastní formát",
|
||||
"DeleteCustomFormat": "Smazat vlastní formát",
|
||||
"DownloadPropersAndRepacksHelpTextWarning": "Použijte automatické formáty pro automatické upgrady na Propers / Repacks",
|
||||
"DownloadedUnableToImportCheckLogsForDetails": "Staženo - Nelze importovat: zkontrolujte podrobnosti v protokolech",
|
||||
"ExportCustomFormat": "Exportovat vlastní formát",
|
||||
|
|
@ -590,8 +590,8 @@
|
|||
"ColonReplacement": "Nahrazení dvojtečky",
|
||||
"Disabled": "Zakázáno",
|
||||
"DownloadClientRootFolderHealthCheckMessage": "Stahovací klient {downloadClientName} umístí stažené soubory do kořenové složky {rootFolderPath}. Neměli byste stahovat do kořenové složky.",
|
||||
"DownloadClientCheckNoneAvailableMessage": "Není k dispozici žádný klient pro stahování",
|
||||
"DownloadClientCheckUnableToCommunicateMessage": "S uživatelem {0} nelze komunikovat.",
|
||||
"DownloadClientCheckNoneAvailableMessage": "Není dostupný žádný klient pro stahování",
|
||||
"DownloadClientCheckUnableToCommunicateMessage": "Nepodařilo se spojit s {0}.",
|
||||
"DownloadClientStatusCheckSingleClientMessage": "Stahování klientů není k dispozici z důvodu selhání: {0}",
|
||||
"ImportListStatusCheckAllClientMessage": "Všechny seznamy nejsou k dispozici z důvodu selhání",
|
||||
"IndexerLongTermStatusCheckAllClientMessage": "Všechny indexery nejsou k dispozici z důvodu selhání po dobu delší než 6 hodin",
|
||||
|
|
@ -616,11 +616,11 @@
|
|||
"ImportMechanismHealthCheckMessage": "Povolit zpracování dokončeného stahování",
|
||||
"IndexerRssHealthCheckNoAvailableIndexers": "Všechny indexery podporující rss jsou dočasně nedostupné kvůli nedávným chybám indexeru",
|
||||
"IndexerRssHealthCheckNoIndexers": "Nejsou k dispozici žádné indexery se zapnutou synchronizací RSS, {appName} nové verze automaticky nezachytí",
|
||||
"IndexerSearchCheckNoInteractiveMessage": "Při povoleném interaktivním vyhledávání, nejsou dostupné žádné indexovací moduly, {appName} neposkytne žádné interaktivní výsledky hledání",
|
||||
"IndexerSearchCheckNoInteractiveMessage": "Nejsou dostupné žádné indexery s povoleným interaktivním vyhledáváním, {appName} nemůže poskytnout žádné výsledky interaktivního hledání",
|
||||
"IndexerStatusCheckAllClientMessage": "Všechny indexery nejsou k dispozici z důvodu selhání",
|
||||
"UpdateCheckUINotWritableMessage": "Aktualizaci nelze nainstalovat, protože uživatelská složka „{0}“ není zapisovatelná uživatelem „{1}“.",
|
||||
"DeleteRemotePathMapping": "Upravit vzdálené mapování cesty",
|
||||
"DeleteRemotePathMappingMessageText": "Opravdu chcete toto vzdálené mapování cesty odstranit?",
|
||||
"DeleteRemotePathMapping": "Smazat mapování vzdálené cesty",
|
||||
"DeleteRemotePathMappingMessageText": "Opravdu chcete smazat toto mapování vzdálené cesty?",
|
||||
"BlocklistReleases": "Blocklist pro vydání",
|
||||
"FailedToLoadQueue": "Načtení fronty se nezdařilo",
|
||||
"QueueIsEmpty": "Fronta je prázdná",
|
||||
|
|
@ -637,17 +637,17 @@
|
|||
"ApplyTagsHelpTextAdd": "Přidat: Přidat štítky do existujícího seznamu štítků",
|
||||
"ApplyTagsHelpTextRemove": "Odebrat: Odebrat zadané štítky",
|
||||
"ApplyTagsHelpTextReplace": "Nahradit: Nahradit štítky zadanými štítky (prázdné pole vymaže všechny štítky)",
|
||||
"DeleteSelectedIndexers": "Odstranit indexer",
|
||||
"DeleteSelectedIndexers": "Smazat indexer(y)",
|
||||
"NoEventsFound": "Nebyly nalezeny žádné události",
|
||||
"Yes": "Ano",
|
||||
"RemoveSelectedItemQueueMessageText": "Opravdu chcete odebrat 1 položku z fronty?",
|
||||
"RemoveSelectedItemsQueueMessageText": "Opravdu chcete odebrat {0} položek z fronty?",
|
||||
"DeleteSelectedDownloadClientsMessageText": "Opravdu chcete smazat {count} vybraných klientů pro stahování?",
|
||||
"DeleteSelectedDownloadClientsMessageText": "Opravdu chcete smazat {count} vybraný(ch) klient(ů) pro stahování?",
|
||||
"DeleteSelectedIndexersMessageText": "Opravdu chcete smazat {count} vybraný(ch) indexer(ů)?",
|
||||
"ApplyTagsHelpTextHowToApplyArtists": "Jak použít značky na vybrané umělce",
|
||||
"ApplyTagsHelpTextHowToApplyImportLists": "Jak použít značky na vybrané seznamy k importu",
|
||||
"ApplyTagsHelpTextHowToApplyIndexers": "Jak použít štítky na vybrané indexery",
|
||||
"DeleteSelectedImportListsMessageText": "Opravdu chcete smazat {count} vybraných seznamů k importu?",
|
||||
"DeleteSelectedImportListsMessageText": "Opravdu chcete smazat {count} vybraný(ch) importní(ch) seznam(ů)?",
|
||||
"ApplyTagsHelpTextHowToApplyDownloadClients": "Jak použít značky na vybrané klienty pro stahování",
|
||||
"SuggestTranslationChange": "Navrhnout změnu překladu",
|
||||
"UpdateSelected": "Aktualizace vybrána",
|
||||
|
|
@ -696,7 +696,7 @@
|
|||
"MetadataProfiles": "profil metadat",
|
||||
"Theme": "Motiv",
|
||||
"BypassIfAboveCustomFormatScore": "Obejít, pokud je vyšší než skóre vlastního formátu",
|
||||
"Discography": "diskografie",
|
||||
"Discography": "Diskografie",
|
||||
"CountDownloadClientsSelected": "{selectedCount} klientů ke stahování vybráno",
|
||||
"Season": "Řada",
|
||||
"Enabled": "Povoleno",
|
||||
|
|
@ -719,7 +719,7 @@
|
|||
"Library": "Knihovna",
|
||||
"CatalogNumber": "Katalogové číslo",
|
||||
"Album": "Album",
|
||||
"DeleteCondition": "Odstranit podmínku",
|
||||
"DeleteCondition": "Smazat podmínku",
|
||||
"EditMetadataProfile": "profil metadat",
|
||||
"IndexerDownloadClientHelpText": "Zvolte, který klient pro stahování bude použit pro zachytávání z toho indexeru",
|
||||
"AlbumReleaseDate": "Datum vydání alba",
|
||||
|
|
@ -772,7 +772,7 @@
|
|||
"DownloadClientRemovesCompletedDownloadsHealthCheckMessage": "Klient pro stahování {0} je nastaven, aby odstraňoval dokončené stahování. To může vést k tomu, že stažená data budou z klienta odstraněna dříve, než je {1} bude moci importovat.",
|
||||
"ImportListRootFolderMissingRootHealthCheckMessage": "Chybí kořenový adresář pro import seznamu: {0}",
|
||||
"ImportListRootFolderMultipleMissingRootsHealthCheckMessage": "Několik kořenových adresářů chybí pro seznamy importu: {0}",
|
||||
"BlocklistReleaseHelpText": "Zabránit {appName}u v opětovném sebrání tohoto vydání",
|
||||
"BlocklistReleaseHelpText": "Zabránit {appName}u v opětovném stažení těchto souborů",
|
||||
"Overview": "Přehled",
|
||||
"PosterOptions": "Možnosti plakátu",
|
||||
"DownloadClientTagHelpText": "Tohoto klienta pro stahování používat pouze pro filmy s alespoň jednou odpovídající značkou. Pro použití se všemi filmy ponechte prázdné pole.",
|
||||
|
|
@ -780,7 +780,7 @@
|
|||
"IndexerTagHelpText": "Tohoto klienta pro stahování používat pouze pro filmy s alespoň jednou odpovídající značkou. Pro použití se všemi filmy ponechte prázdné pole.",
|
||||
"CountArtistsSelected": "{count} vybraných seznamů pro import",
|
||||
"GrabId": "Chyť ID",
|
||||
"DeleteArtistFolderHelpText": "Odstraňte složku filmu a její obsah",
|
||||
"DeleteArtistFolderHelpText": "Smazat složku umělce včetně jejího obsahu",
|
||||
"Large": "Velký",
|
||||
"RenameFiles": "Přejmenovat soubory",
|
||||
"Posters": "Plakáty",
|
||||
|
|
@ -797,14 +797,14 @@
|
|||
"ReleaseProfile": "profil vydání",
|
||||
"AutoTaggingNegateHelpText": "Pokud je zaškrtnuto, pravidlo automatického značkování se nepoužije, pokud odpovídá této podmínce {implementationName}.",
|
||||
"AutoTaggingRequiredHelpText": "Tato podmínka {implementationName} musí odpovídat, aby se pravidlo automatického označování použilo. V opačném případě postačí jediná shoda s {implementationName}.",
|
||||
"CloneAutoTag": "Klonovat automatické značky",
|
||||
"CloneAutoTag": "Klonovat automatické štítky",
|
||||
"DeleteArtistFolderCountConfirmation": "Opravdu chcete smazat {count} vybraných umělců?",
|
||||
"DeleteSpecification": "Smazat oznámení",
|
||||
"DeleteSpecificationHelpText": "Opravdu chcete smazat oznámení '{name}'?",
|
||||
"DeleteSpecification": "Smazat specifikaci",
|
||||
"DeleteSpecificationHelpText": "Opravdu chcete smazat specifikaci '{name}'?",
|
||||
"Small": "Malý",
|
||||
"BypassIfHighestQualityHelpText": "Obejít zpoždění, když má vydání nejvyšší povolenou kvalitu v profilu kvality s preferovaným protokolem",
|
||||
"AutoTagging": "Automatické značkování",
|
||||
"ConditionUsingRegularExpressions": "Tato podmínka odpovídá regulárním výrazům. Všimněte si, že znaky `\\^$.|?*+()[{` mají speciální význam a je třeba je negovat pomocí `\\`",
|
||||
"ConditionUsingRegularExpressions": "Tato podmínka používá regulární výrazy. Mějte na paměti, že znaky `\\^$.|?*+()[{` mají speciální význam a je třeba je escapovat pomocí `\\`",
|
||||
"Connection": "Spojení",
|
||||
"ImportList": "Seznam k importu",
|
||||
"NoLimitForAnyDuration": "Žádné omezení za běhu",
|
||||
|
|
@ -814,7 +814,7 @@
|
|||
"ImportLists": "Seznam k importu",
|
||||
"ExtraFileExtensionsHelpText": "Seznam extra souborů k importu oddělených čárkami (.nfo bude importován jako .nfo-orig)",
|
||||
"ExtraFileExtensionsHelpTextsExamples": "Příklady: „.sub, .nfo“ nebo „sub, nfo“",
|
||||
"DeleteArtistFoldersHelpText": "Odstraňte složku filmu a její obsah",
|
||||
"DeleteArtistFoldersHelpText": "Smazat složky umělců včetně jejich obsahu",
|
||||
"RemoveQueueItemConfirmation": "Opravdu chcete odebrat položku „{sourceTitle}“ z fronty?",
|
||||
"AutoRedownloadFailed": "Opětovné stažení se nezdařilo",
|
||||
"AutoRedownloadFailedFromInteractiveSearch": "Opětovné stažení z interaktivního vyhledávání se nezdařilo",
|
||||
|
|
@ -865,15 +865,15 @@
|
|||
"ChangeCategory": "Změnit kategorii",
|
||||
"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.",
|
||||
"ConnectionSettingsUrlBaseHelpText": "Přidá předponu do {connectionName} url, jako např. {url}",
|
||||
"BlocklistOnlyHint": "Blokovat a nehledat náhradu",
|
||||
"BlocklistOnlyHint": "Blacklistovat a nehledat náhradu",
|
||||
"Any": "Jakákoliv",
|
||||
"BuiltIn": "Vestavěný",
|
||||
"Script": "Skript",
|
||||
"DeleteSelectedCustomFormats": "Odstranění vlastního formátu",
|
||||
"DeleteSelectedCustomFormatsMessageText": "Opravdu chcete smazat {count} vybraných seznamů k importu?",
|
||||
"DeleteSelectedCustomFormats": "Smazat vlastní formát(y)",
|
||||
"DeleteSelectedCustomFormatsMessageText": "Opravdu chcete smazat {count} vybraný(ch) vlastní(ch) formát(ů)?",
|
||||
"IncludeCustomFormatWhenRenaming": "Při přejmenování zahrnout vlastní formát",
|
||||
"AptUpdater": "Použít apt pro instalaci aktualizace",
|
||||
"DockerUpdater": "aktualizujte kontejner dockeru, abyste aktualizaci obdrželi",
|
||||
"DockerUpdater": "Pro získání aktualizace je třeba aktualizovat docker kontejner",
|
||||
"InstallLatest": "Nainstalujte nejnovější",
|
||||
"Shutdown": "Vypnout",
|
||||
"UpdateAppDirectlyLoadError": "{appName} nelze aktualizovat přímo,",
|
||||
|
|
@ -894,8 +894,8 @@
|
|||
"AddAlbumWithTitle": "Přidat {albumTitle}",
|
||||
"DownloadClientDelugeSettingsDirectory": "Adresář stahování",
|
||||
"ClickToChangeIndexerFlags": "Kliknutím změníte příznaky indexeru",
|
||||
"CustomFormatsSpecificationRegularExpression": "Běžný výraz",
|
||||
"Donate": "Daruj",
|
||||
"CustomFormatsSpecificationRegularExpression": "Regulární výraz",
|
||||
"Donate": "Darovat",
|
||||
"Implementation": "Implementace",
|
||||
"NoCutoffUnmetItems": "Žádné neodpovídající nesplněné položky",
|
||||
"HealthMessagesInfoBox": "Další informace o příčině těchto zpráv o kontrole zdraví najdete kliknutím na odkaz wiki (ikona knihy) na konci řádku nebo kontrolou [logů]({link}). Pokud máte potíže s interpretací těchto zpráv, můžete se obrátit na naši podporu, a to na níže uvedených odkazech.",
|
||||
|
|
@ -906,13 +906,13 @@
|
|||
"AllowFingerprinting": "Povolit digitální otisk (Fingerprinting)",
|
||||
"BlocklistAndSearchHint": "Začne hledat náhradu po blokaci",
|
||||
"BlocklistAndSearchMultipleHint": "Začne vyhledávat náhrady po blokaci",
|
||||
"BlocklistOnly": "Pouze seznam blokování",
|
||||
"BlocklistOnly": "Pouze blacklistovat",
|
||||
"ChangeCategoryHint": "Změní stahování do kategorie „Post-Import“ z aplikace Download Client",
|
||||
"ChangeCategoryMultipleHint": "Změní stahování do kategorie „Post-Import“ z aplikace Download Client",
|
||||
"CountCustomFormatsSelected": "{count} vybraný vlastní formát(y)",
|
||||
"DeleteSelected": "Smazat vybrané",
|
||||
"DoNotBlocklist": "Nepřidávat do Seznamu blokování",
|
||||
"DoNotBlocklistHint": "Odstraň bez přidání do seznamu blokování",
|
||||
"DoNotBlocklist": "Nepřidávat do blacklistu",
|
||||
"DoNotBlocklistHint": "Smazat bez přidání do blacklistu",
|
||||
"DownloadClientAriaSettingsDirectoryHelpText": "Volitelné umístění pro stahování, pokud chcete použít výchozí umístění Aria2, ponechte prázdné",
|
||||
"DownloadClientQbittorrentSettingsContentLayout": "Rozvržení obsahu",
|
||||
"DownloadClientQbittorrentSettingsContentLayoutHelpText": "Zda použít rozvržení obsahu nakonfigurované v qBittorrentu, původní rozvržení z torrentu nebo vždy vytvořit podsložku (qBittorrent 4.3.2+)",
|
||||
|
|
@ -1226,7 +1226,7 @@
|
|||
"EnabledHelpText": "Zaškrnutím zapnete profil vydání",
|
||||
"EndedAllTracksDownloaded": "Skončeno (Všechny skladby staženy)",
|
||||
"EpisodeDoesNotHaveAnAbsoluteEpisodeNumber": "Díl nemá absolutní číslo",
|
||||
"ExpandEPByDefaultHelpText": "EPs",
|
||||
"ExpandEPByDefaultHelpText": "EP",
|
||||
"ExpandItemsByDefault": "Automaticky rozbalit položky",
|
||||
"ExistingTagsScrubbed": "Stávající tagy vyčištěny",
|
||||
"ExpandOtherByDefaultHelpText": "Ostatní",
|
||||
|
|
|
|||
|
|
@ -594,6 +594,10 @@
|
|||
"ImportLists": "Import Lists",
|
||||
"ImportListsSettingsSummary": "Import from another {appName} instance or Trakt lists and manage list exclusions",
|
||||
"ImportMechanismHealthCheckMessage": "Enable Completed Download Handling",
|
||||
"ImportScriptPath": "Import Script Path",
|
||||
"ImportScriptPathHelpText": "The path to the script to use for importing",
|
||||
"ImportUsingScript": "Import Using Script",
|
||||
"ImportUsingScriptHelpText": "Copy files for importing using a script (ex. for transcoding)",
|
||||
"ImportedTo": "Imported To",
|
||||
"Importing": "Importing",
|
||||
"Inactive": "Inactive",
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ namespace NzbDrone.Core.MediaCover
|
|||
{
|
||||
public interface IMapCoversToLocal
|
||||
{
|
||||
void ConvertToLocalUrls(int entityId, MediaCoverEntity coverEntity, IEnumerable<MediaCover> covers);
|
||||
void ConvertToLocalUrls(int entityId, MediaCoverEntity coverEntity, ICollection<MediaCover> covers);
|
||||
string GetCoverPath(int entityId, MediaCoverEntity coverEntity, MediaCoverTypes coverType, string extension, int? height = null);
|
||||
bool EnsureAlbumCovers(Album album);
|
||||
}
|
||||
|
|
@ -82,7 +82,7 @@ public string GetCoverPath(int entityId, MediaCoverEntity coverEntity, MediaCove
|
|||
return Path.Combine(GetArtistCoverPath(entityId), coverType.ToString().ToLower() + heightSuffix + GetExtension(coverType, extension));
|
||||
}
|
||||
|
||||
public void ConvertToLocalUrls(int entityId, MediaCoverEntity coverEntity, IEnumerable<MediaCover> covers)
|
||||
public void ConvertToLocalUrls(int entityId, MediaCoverEntity coverEntity, ICollection<MediaCover> covers)
|
||||
{
|
||||
if (entityId == 0)
|
||||
{
|
||||
|
|
@ -92,34 +92,39 @@ public void ConvertToLocalUrls(int entityId, MediaCoverEntity coverEntity, IEnum
|
|||
mediaCover.RemoteUrl = mediaCover.Url;
|
||||
mediaCover.Url = _mediaCoverProxy.RegisterUrl(mediaCover.RemoteUrl);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
else
|
||||
|
||||
if (!covers.Any())
|
||||
{
|
||||
foreach (var mediaCover in covers)
|
||||
PopulateCoverWithCache(entityId, coverEntity, covers);
|
||||
}
|
||||
|
||||
foreach (var mediaCover in covers)
|
||||
{
|
||||
if (mediaCover.CoverType == MediaCoverTypes.Unknown)
|
||||
{
|
||||
if (mediaCover.CoverType == MediaCoverTypes.Unknown)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var filePath = GetCoverPath(entityId, coverEntity, mediaCover.CoverType, mediaCover.Extension, null);
|
||||
var filePath = GetCoverPath(entityId, coverEntity, mediaCover.CoverType, mediaCover.Extension, null);
|
||||
|
||||
mediaCover.RemoteUrl = mediaCover.Url;
|
||||
mediaCover.RemoteUrl = mediaCover.Url;
|
||||
|
||||
if (coverEntity == MediaCoverEntity.Album)
|
||||
{
|
||||
mediaCover.Url = _configFileProvider.UrlBase + @"/MediaCover/Albums/" + entityId + "/" + mediaCover.CoverType.ToString().ToLower() + GetExtension(mediaCover.CoverType, mediaCover.Extension);
|
||||
}
|
||||
else
|
||||
{
|
||||
mediaCover.Url = _configFileProvider.UrlBase + @"/MediaCover/" + entityId + "/" + mediaCover.CoverType.ToString().ToLower() + GetExtension(mediaCover.CoverType, mediaCover.Extension);
|
||||
}
|
||||
if (coverEntity == MediaCoverEntity.Album)
|
||||
{
|
||||
mediaCover.Url = _configFileProvider.UrlBase + @"/MediaCover/Albums/" + entityId + "/" + mediaCover.CoverType.ToString().ToLower() + GetExtension(mediaCover.CoverType, mediaCover.Extension);
|
||||
}
|
||||
else
|
||||
{
|
||||
mediaCover.Url = _configFileProvider.UrlBase + @"/MediaCover/" + entityId + "/" + mediaCover.CoverType.ToString().ToLower() + GetExtension(mediaCover.CoverType, mediaCover.Extension);
|
||||
}
|
||||
|
||||
if (_diskProvider.FileExists(filePath))
|
||||
{
|
||||
var lastWrite = _diskProvider.FileGetLastWrite(filePath);
|
||||
mediaCover.Url += "?lastWrite=" + lastWrite.Ticks;
|
||||
}
|
||||
if (_diskProvider.FileExists(filePath))
|
||||
{
|
||||
var lastWrite = _diskProvider.FileGetLastWrite(filePath);
|
||||
mediaCover.Url += "?lastWrite=" + lastWrite.Ticks;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -194,6 +199,35 @@ private bool EnsureArtistCovers(Artist artist)
|
|||
return updated;
|
||||
}
|
||||
|
||||
private void PopulateCoverWithCache(int entityId, MediaCoverEntity coverEntity, ICollection<MediaCover> covers)
|
||||
{
|
||||
var folderPath = coverEntity == MediaCoverEntity.Album ? GetAlbumCoverPath(entityId) : GetArtistCoverPath(entityId);
|
||||
|
||||
if (_diskProvider.FolderExists(folderPath))
|
||||
{
|
||||
foreach (var fileInfo in _diskProvider.GetFileInfos(folderPath))
|
||||
{
|
||||
var fileName = Path.GetFileNameWithoutExtension(fileInfo.Name);
|
||||
var extension = Path.GetExtension(fileInfo.Name);
|
||||
if (fileName.Contains('-'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Enum.TryParse(fileName, true, out MediaCoverTypes coverType) && !covers.Any(c => c.CoverType == coverType))
|
||||
{
|
||||
var filePath = fileInfo.FullName;
|
||||
var diskCover = new MediaCover(coverType, filePath)
|
||||
{
|
||||
RemoteUrl = filePath
|
||||
};
|
||||
|
||||
covers.Add(diskCover);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool EnsureAlbumCovers(Album album)
|
||||
{
|
||||
var updated = false;
|
||||
|
|
|
|||
|
|
@ -74,66 +74,103 @@ public ParsedTrackInfo ReadTags(string path)
|
|||
|
||||
public AudioTag GetTrackMetadata(TrackFile trackfile)
|
||||
{
|
||||
var track = trackfile.Tracks.Value[0];
|
||||
var release = track.AlbumRelease.Value;
|
||||
var album = release.Album.Value;
|
||||
var albumartist = album.Artist.Value;
|
||||
var artist = track.ArtistMetadata.Value;
|
||||
|
||||
string imageFile = null;
|
||||
long imageSize = 0;
|
||||
|
||||
if (_configService.EmbedCoverArt)
|
||||
try
|
||||
{
|
||||
var cover = album.Images.FirstOrDefault(x => x.CoverType == MediaCoverTypes.Cover);
|
||||
if (cover != null)
|
||||
if (trackfile.Tracks?.Value == null || !trackfile.Tracks.Value.Any())
|
||||
{
|
||||
imageFile = _mediaCoverService.GetCoverPath(album.Id, MediaCoverEntity.Album, cover.CoverType, cover.Extension, null);
|
||||
_logger.Trace("Embedding: {0}", imageFile);
|
||||
var fileInfo = _diskProvider.GetFileInfo(imageFile);
|
||||
if (fileInfo.Exists)
|
||||
throw new InvalidOperationException("Unable to write tags: Track information is missing from the database");
|
||||
}
|
||||
|
||||
var track = trackfile.Tracks.Value[0];
|
||||
|
||||
if (track.AlbumRelease?.Value == null)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to write tags: Album release information is missing from the database");
|
||||
}
|
||||
|
||||
var release = track.AlbumRelease.Value;
|
||||
|
||||
if (release.Album?.Value == null)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to write tags: Album information is missing from the database");
|
||||
}
|
||||
|
||||
var album = release.Album.Value;
|
||||
|
||||
if (album.Artist?.Value == null)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to write tags: Artist information is missing from the database");
|
||||
}
|
||||
|
||||
var albumartist = album.Artist.Value;
|
||||
|
||||
if (track.ArtistMetadata?.Value == null)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to write tags: Artist metadata is missing from the database");
|
||||
}
|
||||
|
||||
var artist = track.ArtistMetadata.Value;
|
||||
|
||||
string imageFile = null;
|
||||
long imageSize = 0;
|
||||
|
||||
if (_configService.EmbedCoverArt)
|
||||
{
|
||||
var cover = album.Images.FirstOrDefault(x => x.CoverType == MediaCoverTypes.Cover);
|
||||
if (cover != null)
|
||||
{
|
||||
imageSize = fileInfo.Length;
|
||||
}
|
||||
else
|
||||
{
|
||||
imageFile = null;
|
||||
imageFile = _mediaCoverService.GetCoverPath(album.Id, MediaCoverEntity.Album, cover.CoverType, cover.Extension, null);
|
||||
_logger.Trace("Embedding: {0}", imageFile);
|
||||
var fileInfo = _diskProvider.GetFileInfo(imageFile);
|
||||
if (fileInfo.Exists)
|
||||
{
|
||||
imageSize = fileInfo.Length;
|
||||
}
|
||||
else
|
||||
{
|
||||
imageFile = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new AudioTag
|
||||
{
|
||||
Title = track.Title,
|
||||
Performers = new[] { artist.Name },
|
||||
AlbumArtists = new[] { albumartist.Name },
|
||||
Track = (uint)track.AbsoluteTrackNumber,
|
||||
TrackCount = (uint)release.Tracks.Value.Count(x => x.MediumNumber == track.MediumNumber),
|
||||
Album = album.Title,
|
||||
Disc = (uint)track.MediumNumber,
|
||||
DiscCount = (uint)release.Media.Count,
|
||||
|
||||
// We may have omitted media so index in the list isn't the same as medium number
|
||||
Media = release.Media.SingleOrDefault(x => x.Number == track.MediumNumber)?.Format,
|
||||
Date = release.ReleaseDate ?? album.ReleaseDate,
|
||||
Year = (uint)(album.ReleaseDate?.Year ?? release.ReleaseDate?.Year ?? 0),
|
||||
OriginalReleaseDate = album.ReleaseDate,
|
||||
OriginalYear = (uint)(album.ReleaseDate?.Year ?? 0),
|
||||
Publisher = release.Label.FirstOrDefault(),
|
||||
Genres = album.Genres.Any() ? album.Genres.ToArray() : artist.Genres.ToArray(),
|
||||
ImageFile = imageFile,
|
||||
ImageSize = imageSize,
|
||||
MusicBrainzReleaseCountry = IsoCountries.Find(release.Country.FirstOrDefault())?.TwoLetterCode,
|
||||
MusicBrainzReleaseStatus = release.Status.ToLower(),
|
||||
MusicBrainzReleaseType = album.AlbumType.ToLower(),
|
||||
MusicBrainzReleaseId = release.ForeignReleaseId,
|
||||
MusicBrainzArtistId = artist.ForeignArtistId,
|
||||
MusicBrainzReleaseArtistId = albumartist.ForeignArtistId,
|
||||
MusicBrainzReleaseGroupId = album.ForeignAlbumId,
|
||||
MusicBrainzTrackId = track.ForeignRecordingId,
|
||||
MusicBrainzReleaseTrackId = track.ForeignTrackId,
|
||||
MusicBrainzAlbumComment = album.Disambiguation,
|
||||
};
|
||||
}
|
||||
|
||||
return new AudioTag
|
||||
catch (Exception ex)
|
||||
{
|
||||
Title = track.Title,
|
||||
Performers = new[] { artist.Name },
|
||||
AlbumArtists = new[] { albumartist.Name },
|
||||
Track = (uint)track.AbsoluteTrackNumber,
|
||||
TrackCount = (uint)release.Tracks.Value.Count(x => x.MediumNumber == track.MediumNumber),
|
||||
Album = album.Title,
|
||||
Disc = (uint)track.MediumNumber,
|
||||
DiscCount = (uint)release.Media.Count,
|
||||
|
||||
// We may have omitted media so index in the list isn't the same as medium number
|
||||
Media = release.Media.SingleOrDefault(x => x.Number == track.MediumNumber)?.Format,
|
||||
Date = release.ReleaseDate ?? album.ReleaseDate,
|
||||
Year = (uint)(album.ReleaseDate?.Year ?? release.ReleaseDate?.Year ?? 0),
|
||||
OriginalReleaseDate = album.ReleaseDate,
|
||||
OriginalYear = (uint)(album.ReleaseDate?.Year ?? 0),
|
||||
Publisher = release.Label.FirstOrDefault(),
|
||||
Genres = album.Genres.Any() ? album.Genres.ToArray() : artist.Genres.ToArray(),
|
||||
ImageFile = imageFile,
|
||||
ImageSize = imageSize,
|
||||
MusicBrainzReleaseCountry = IsoCountries.Find(release.Country.FirstOrDefault())?.TwoLetterCode,
|
||||
MusicBrainzReleaseStatus = release.Status.ToLower(),
|
||||
MusicBrainzReleaseType = album.AlbumType.ToLower(),
|
||||
MusicBrainzReleaseId = release.ForeignReleaseId,
|
||||
MusicBrainzArtistId = artist.ForeignArtistId,
|
||||
MusicBrainzReleaseArtistId = albumartist.ForeignArtistId,
|
||||
MusicBrainzReleaseGroupId = album.ForeignAlbumId,
|
||||
MusicBrainzTrackId = track.ForeignRecordingId,
|
||||
MusicBrainzReleaseTrackId = track.ForeignTrackId,
|
||||
MusicBrainzAlbumComment = album.Disambiguation,
|
||||
};
|
||||
_logger.Error(ex, "Failed to get track metadata for {0}", trackfile.Path);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateTrackfileSizeAndModified(TrackFile trackfile, string path)
|
||||
|
|
|
|||
|
|
@ -142,6 +142,35 @@ public void Scan(List<string> folders = null, FilterFilesType filter = FilterFil
|
|||
mediaFileList.AddRange(files);
|
||||
}
|
||||
|
||||
var artists = _artistService.GetArtists(artistIds);
|
||||
|
||||
// Check for missing artist folders if specific artists are being scanned
|
||||
if (artistIds != null && artistIds.Any())
|
||||
{
|
||||
foreach (var artist in artists)
|
||||
{
|
||||
if (!_diskProvider.FolderExists(artist.Path))
|
||||
{
|
||||
if (_configService.CreateEmptyArtistFolders)
|
||||
{
|
||||
if (_configService.DeleteEmptyFolders)
|
||||
{
|
||||
_logger.Debug("Not creating missing artist folder: {0} because delete empty folders is enabled", artist.Path);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug("Creating missing artist folder: {0}", artist.Path);
|
||||
_diskProvider.CreateFolder(artist.Path);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug("Artist folder doesn't exist: {0}", artist.Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
musicFilesStopwatch.Stop();
|
||||
_logger.Trace("Finished getting track files for:\n{0} [{1}]", folders.ConcatToString("\n"), musicFilesStopwatch.Elapsed);
|
||||
|
||||
|
|
@ -211,7 +240,6 @@ public void Scan(List<string> folders = null, FilterFilesType filter = FilterFil
|
|||
|
||||
_logger.Debug($"Updated info for {updatedFiles.Count} known files");
|
||||
|
||||
var artists = _artistService.GetArtists(artistIds);
|
||||
foreach (var artist in artists)
|
||||
{
|
||||
CompletedScanning(artist);
|
||||
|
|
|
|||
125
src/NzbDrone.Core/MediaFiles/ScriptImportDecider.cs
Normal file
125
src/NzbDrone.Core/MediaFiles/ScriptImportDecider.cs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Common.Processes;
|
||||
using NzbDrone.Core.Configuration;
|
||||
using NzbDrone.Core.CustomFormats;
|
||||
using NzbDrone.Core.Download;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Tags;
|
||||
|
||||
namespace NzbDrone.Core.MediaFiles
|
||||
{
|
||||
public interface IImportScript
|
||||
{
|
||||
public ScriptImportDecision TryImport(string sourcePath, string destinationFilePath, LocalTrack localTrack, TrackFile trackFile, TransferMode mode, DownloadClientItem downloadClientItem = null);
|
||||
}
|
||||
|
||||
public class ImportScriptService : IImportScript
|
||||
{
|
||||
private readonly IConfigFileProvider _configFileProvider;
|
||||
private readonly IAudioTagService _audioTagService;
|
||||
private readonly IProcessProvider _processProvider;
|
||||
private readonly IConfigService _configService;
|
||||
private readonly ITagRepository _tagRepository;
|
||||
private readonly ICustomFormatCalculationService _customFormatCalculationService;
|
||||
private readonly Logger _logger;
|
||||
|
||||
public ImportScriptService(IProcessProvider processProvider,
|
||||
IAudioTagService audioTagService,
|
||||
IConfigService configService,
|
||||
IConfigFileProvider configFileProvider,
|
||||
ITagRepository tagRepository,
|
||||
ICustomFormatCalculationService customFormatCalculationService,
|
||||
Logger logger)
|
||||
{
|
||||
_processProvider = processProvider;
|
||||
_audioTagService = audioTagService;
|
||||
_configService = configService;
|
||||
_configFileProvider = configFileProvider;
|
||||
_tagRepository = tagRepository;
|
||||
_customFormatCalculationService = customFormatCalculationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public ScriptImportDecision TryImport(string sourcePath, string destinationFilePath, LocalTrack localTrack, TrackFile trackFile, TransferMode mode, DownloadClientItem downloadClientItem = null)
|
||||
{
|
||||
var artist = localTrack.Artist;
|
||||
var album = localTrack.Album;
|
||||
var downloadClientInfo = downloadClientItem?.DownloadClientInfo;
|
||||
var downloadId = downloadClientItem?.DownloadId;
|
||||
|
||||
if (!_configService.UseScriptImport)
|
||||
{
|
||||
return ScriptImportDecision.DeferMove;
|
||||
}
|
||||
|
||||
var environmentVariables = new StringDictionary
|
||||
{
|
||||
{ "Lidarr_SourcePath", sourcePath },
|
||||
{ "Lidarr_DestinationPath", destinationFilePath },
|
||||
{ "Lidarr_InstanceName", _configFileProvider.InstanceName },
|
||||
{ "Lidarr_ApplicationUrl", _configService.ApplicationUrl },
|
||||
{ "Lidarr_TransferMode", mode.ToString() },
|
||||
{ "Lidarr_Artist_Id", artist.Id.ToString() },
|
||||
{ "Lidarr_Artist_Name", artist.Name },
|
||||
{ "Lidarr_Artist_Path", artist.Path },
|
||||
{ "Lidarr_Artist_MBId", artist.ForeignArtistId },
|
||||
{ "Lidarr_Artist_Tags", string.Join("|", artist.Tags.Select(t => _tagRepository.Get(t).Label)) },
|
||||
{ "Lidarr_Album_Id", album.Id.ToString() },
|
||||
{ "Lidarr_Album_Title", album.Title },
|
||||
{ "Lidarr_Album_MBId", album.ForeignAlbumId },
|
||||
{ "Lidarr_Album_ReleaseDate", album.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty },
|
||||
{ "Lidarr_Album_Genres", string.Join("|", album.Genres) },
|
||||
{ "Lidarr_TrackFile_TrackCount", localTrack.Tracks.Count.ToString() },
|
||||
{ "Lidarr_TrackFile_TrackIds", string.Join(",", localTrack.Tracks.Select(t => t.Id)) },
|
||||
{ "Lidarr_TrackFile_TrackNumbers", string.Join(",", localTrack.Tracks.Select(t => t.TrackNumber)) },
|
||||
{ "Lidarr_TrackFile_TrackTitles", string.Join("|", localTrack.Tracks.Select(t => t.Title)) },
|
||||
{ "Lidarr_TrackFile_Quality", localTrack.Quality.Quality.Name },
|
||||
{ "Lidarr_TrackFile_QualityVersion", localTrack.Quality.Revision.Version.ToString() },
|
||||
{ "Lidarr_TrackFile_ReleaseGroup", localTrack.ReleaseGroup ?? string.Empty },
|
||||
{ "Lidarr_TrackFile_SceneName", localTrack.SceneName ?? string.Empty },
|
||||
{ "Lidarr_Download_Client", downloadClientInfo?.Name ?? string.Empty },
|
||||
{ "Lidarr_Download_Client_Type", downloadClientInfo?.Type ?? string.Empty },
|
||||
{ "Lidarr_Download_Id", downloadId ?? string.Empty }
|
||||
};
|
||||
|
||||
// Audio-specific MediaInfo (no video properties for music files)
|
||||
if (localTrack.FileTrackInfo?.MediaInfo != null)
|
||||
{
|
||||
var mediaInfo = localTrack.FileTrackInfo.MediaInfo;
|
||||
environmentVariables.Add("Lidarr_TrackFile_MediaInfo_AudioChannels", mediaInfo.AudioChannels.ToString());
|
||||
environmentVariables.Add("Lidarr_TrackFile_MediaInfo_AudioCodec", mediaInfo.AudioFormat ?? string.Empty);
|
||||
environmentVariables.Add("Lidarr_TrackFile_MediaInfo_AudioBitRate", mediaInfo.AudioBitrate.ToString());
|
||||
environmentVariables.Add("Lidarr_TrackFile_MediaInfo_AudioSampleRate", mediaInfo.AudioSampleRate.ToString());
|
||||
environmentVariables.Add("Lidarr_TrackFile_MediaInfo_BitsPerSample", mediaInfo.AudioBits.ToString());
|
||||
}
|
||||
|
||||
// CustomFormats for music files
|
||||
var customFormats = _customFormatCalculationService.ParseCustomFormat(localTrack);
|
||||
environmentVariables.Add("Lidarr_TrackFile_CustomFormat", string.Join("|", customFormats.Select(x => x.Name)));
|
||||
|
||||
_logger.Debug("Executing external script: {0}", _configService.ScriptImportPath);
|
||||
|
||||
var processOutput = _processProvider.StartAndCapture(_configService.ScriptImportPath, $"\"{sourcePath}\" \"{destinationFilePath}\"", environmentVariables);
|
||||
|
||||
_logger.Debug("Executed external script: {0} - Status: {1}", _configService.ScriptImportPath, processOutput.ExitCode);
|
||||
_logger.Debug("Script Output: \r\n{0}", string.Join("\r\n", processOutput.Lines));
|
||||
|
||||
switch (processOutput.ExitCode)
|
||||
{
|
||||
case 0: // Copy complete
|
||||
return ScriptImportDecision.MoveComplete;
|
||||
case 2: // Copy complete, file potentially changed, should try renaming again
|
||||
trackFile.MediaInfo = _audioTagService.ReadTags(destinationFilePath).MediaInfo;
|
||||
trackFile.Path = null;
|
||||
return ScriptImportDecision.RenameRequested;
|
||||
case 3: // Let Lidarr handle it
|
||||
return ScriptImportDecision.DeferMove;
|
||||
default: // Error, fail to import
|
||||
throw new ScriptImportException("Moving with script failed! Exit code {0}", processOutput.ExitCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/NzbDrone.Core/MediaFiles/ScriptImportDecision.cs
Normal file
10
src/NzbDrone.Core/MediaFiles/ScriptImportDecision.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
namespace NzbDrone.Core.MediaFiles
|
||||
{
|
||||
public enum ScriptImportDecision
|
||||
{
|
||||
MoveComplete,
|
||||
RenameRequested,
|
||||
RejectExtra,
|
||||
DeferMove
|
||||
}
|
||||
}
|
||||
23
src/NzbDrone.Core/MediaFiles/ScriptImportException.cs
Normal file
23
src/NzbDrone.Core/MediaFiles/ScriptImportException.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
using System;
|
||||
using NzbDrone.Common.Exceptions;
|
||||
|
||||
namespace NzbDrone.Core.MediaFiles
|
||||
{
|
||||
public class ScriptImportException : NzbDroneException
|
||||
{
|
||||
public ScriptImportException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public ScriptImportException(string message, params object[] args)
|
||||
: base(message, args)
|
||||
{
|
||||
}
|
||||
|
||||
public ScriptImportException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ public class TrackFileMovingService : IMoveTrackFiles
|
|||
private readonly IMediaFileAttributeService _mediaFileAttributeService;
|
||||
private readonly IRootFolderService _rootFolderService;
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
private readonly IImportScript _scriptImportDecider;
|
||||
private readonly IConfigService _configService;
|
||||
private readonly Logger _logger;
|
||||
|
||||
|
|
@ -48,6 +49,7 @@ public TrackFileMovingService(ITrackService trackService,
|
|||
IMediaFileAttributeService mediaFileAttributeService,
|
||||
IRootFolderService rootFolderService,
|
||||
IEventAggregator eventAggregator,
|
||||
IImportScript scriptImportDecider,
|
||||
IConfigService configService,
|
||||
Logger logger)
|
||||
{
|
||||
|
|
@ -61,6 +63,7 @@ public TrackFileMovingService(ITrackService trackService,
|
|||
_mediaFileAttributeService = mediaFileAttributeService;
|
||||
_rootFolderService = rootFolderService;
|
||||
_eventAggregator = eventAggregator;
|
||||
_scriptImportDecider = scriptImportDecider;
|
||||
_configService = configService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
|
@ -86,7 +89,7 @@ public TrackFile MoveTrackFile(TrackFile trackFile, LocalTrack localTrack)
|
|||
|
||||
_logger.Debug("Moving track file: {0} to {1}", trackFile.Path, filePath);
|
||||
|
||||
return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.Move);
|
||||
return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.Move, localTrack);
|
||||
}
|
||||
|
||||
public TrackFile CopyTrackFile(TrackFile trackFile, LocalTrack localTrack)
|
||||
|
|
@ -98,14 +101,14 @@ public TrackFile CopyTrackFile(TrackFile trackFile, LocalTrack localTrack)
|
|||
if (_configService.CopyUsingHardlinks)
|
||||
{
|
||||
_logger.Debug("Attempting to hardlink track file: {0} to {1}", trackFile.Path, filePath);
|
||||
return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.HardLinkOrCopy);
|
||||
return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.HardLinkOrCopy, localTrack);
|
||||
}
|
||||
|
||||
_logger.Debug("Copying track file: {0} to {1}", trackFile.Path, filePath);
|
||||
return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.Copy);
|
||||
return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.Copy, localTrack);
|
||||
}
|
||||
|
||||
private TrackFile TransferFile(TrackFile trackFile, Artist artist, List<Track> tracks, string destinationFilePath, TransferMode mode)
|
||||
private TrackFile TransferFile(TrackFile trackFile, Artist artist, List<Track> tracks, string destinationFilePath, TransferMode mode, LocalTrack localTrack = null)
|
||||
{
|
||||
Ensure.That(trackFile, () => trackFile).IsNotNull();
|
||||
Ensure.That(artist, () => artist).IsNotNull();
|
||||
|
|
@ -123,8 +126,31 @@ private TrackFile TransferFile(TrackFile trackFile, Artist artist, List<Track> t
|
|||
throw new SameFilenameException("File not moved, source and destination are the same", trackFilePath);
|
||||
}
|
||||
|
||||
_rootFolderWatchingService.ReportFileSystemChangeBeginning(trackFilePath, destinationFilePath);
|
||||
_diskTransferService.TransferFile(trackFilePath, destinationFilePath, mode);
|
||||
var transfer = true;
|
||||
|
||||
if (localTrack is not null)
|
||||
{
|
||||
var scriptImportDecision = _scriptImportDecider.TryImport(trackFilePath, destinationFilePath, localTrack, trackFile, mode, null);
|
||||
|
||||
switch (scriptImportDecision)
|
||||
{
|
||||
case ScriptImportDecision.DeferMove:
|
||||
break;
|
||||
case ScriptImportDecision.RenameRequested:
|
||||
MoveTrackFile(trackFile, artist);
|
||||
transfer = false;
|
||||
break;
|
||||
case ScriptImportDecision.MoveComplete:
|
||||
transfer = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (transfer)
|
||||
{
|
||||
_rootFolderWatchingService.ReportFileSystemChangeBeginning(trackFilePath, destinationFilePath);
|
||||
_diskTransferService.TransferFile(trackFilePath, destinationFilePath, mode);
|
||||
}
|
||||
|
||||
trackFile.Path = destinationFilePath;
|
||||
|
||||
|
|
|
|||
|
|
@ -125,8 +125,8 @@ private static string Clean(string input)
|
|||
public void AddString(string key, string value, string target)
|
||||
{
|
||||
// Adds a penaltly based on the distance between value and target
|
||||
var cleanValue = Clean(value);
|
||||
var cleanTarget = Clean(target);
|
||||
var cleanValue = Clean(value ?? string.Empty);
|
||||
var cleanTarget = Clean(target ?? string.Empty);
|
||||
|
||||
if (cleanValue.IsNullOrWhiteSpace() && cleanTarget.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
|
|
|
|||
|
|
@ -250,7 +250,18 @@ public List<ImportResult> Import(List<ImportDecision<LocalTrack>> decisions, boo
|
|||
_mediaFileService.Delete(previousFile, DeleteMediaFileReason.ManualOverride);
|
||||
}
|
||||
|
||||
_audioTagService.WriteTags(trackFile, false);
|
||||
try
|
||||
{
|
||||
_audioTagService.WriteTags(trackFile, false);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.Warn(ex, "Failed to write tags for {0}: {1}. Try refreshing the artist to fix missing information.", trackFile.Path, ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Unexpected error writing tags for existing track file {0}", trackFile.Path);
|
||||
}
|
||||
}
|
||||
|
||||
filesToAdd.Add(trackFile);
|
||||
|
|
|
|||
|
|
@ -123,10 +123,27 @@ public List<ManualImportItem> GetMediaFiles(string path, string downloadId, Arti
|
|||
AddNewArtists = false
|
||||
};
|
||||
|
||||
var decision = _importDecisionMaker.GetImportDecisions(files, null, null, config);
|
||||
var result = MapItem(decision.First(), downloadId, replaceExistingFiles, false);
|
||||
var decisions = _importDecisionMaker.GetImportDecisions(files, null, null, config);
|
||||
|
||||
return new List<ManualImportItem> { result };
|
||||
if (decisions.Any())
|
||||
{
|
||||
var result = MapItem(decisions.First(), downloadId, replaceExistingFiles, false);
|
||||
return new List<ManualImportItem> { result };
|
||||
}
|
||||
|
||||
return new List<ManualImportItem>
|
||||
{
|
||||
new ManualImportItem()
|
||||
{
|
||||
Id = HashConverter.GetHashInt31(path),
|
||||
DownloadId = downloadId,
|
||||
Path = path,
|
||||
Name = Path.GetFileNameWithoutExtension(path),
|
||||
Size = _diskProvider.GetFileSize(path),
|
||||
Rejections = new List<Rejection> { new Rejection("Unable to process file") },
|
||||
ReplaceExistingFiles = replaceExistingFiles
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return ProcessFolder(path, downloadId, artist, filter, replaceExistingFiles);
|
||||
|
|
@ -150,6 +167,13 @@ private List<ManualImportItem> ProcessFolder(string folder, string downloadId, A
|
|||
}
|
||||
|
||||
var artistFiles = _diskScanService.GetAudioFiles(folder).ToList();
|
||||
|
||||
if (artist == null && artistFiles.Count > 100)
|
||||
{
|
||||
_logger.Warn("Unable to determine artist from folder name and found more than 100 files. Skipping parsing");
|
||||
return ProcessDownloadDirectory(folder, artistFiles);
|
||||
}
|
||||
|
||||
var idOverrides = new IdentificationOverrides
|
||||
{
|
||||
Artist = artist
|
||||
|
|
@ -184,6 +208,23 @@ private List<ManualImportItem> ProcessFolder(string folder, string downloadId, A
|
|||
return newItems.Concat(existingItems).ToList();
|
||||
}
|
||||
|
||||
private List<ManualImportItem> ProcessDownloadDirectory(string folder, List<IFileInfo> audioFiles)
|
||||
{
|
||||
var items = new List<ManualImportItem>();
|
||||
|
||||
foreach (var file in audioFiles)
|
||||
{
|
||||
var localTrack = new LocalTrack();
|
||||
localTrack.Path = file.FullName;
|
||||
localTrack.Quality = new QualityModel(Quality.Unknown);
|
||||
localTrack.Size = file.Length;
|
||||
|
||||
items.Add(MapItem(new ImportDecision<LocalTrack>(localTrack), null, false, false));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public List<ManualImportItem> UpdateItems(List<ManualImportItem> items)
|
||||
{
|
||||
var replaceExistingFiles = items.All(x => x.ReplaceExistingFiles);
|
||||
|
|
|
|||
|
|
@ -328,19 +328,36 @@ public List<Album> SearchForNewAlbum(string title, string artist)
|
|||
|
||||
public List<Album> SearchForNewAlbumByRecordingIds(List<string> recordingIds)
|
||||
{
|
||||
var ids = recordingIds.Where(x => x.IsNotNullOrWhiteSpace()).Distinct();
|
||||
var httpRequest = _requestBuilder.GetRequestBuilder().Create()
|
||||
.SetSegment("route", "search/fingerprint")
|
||||
.Build();
|
||||
try
|
||||
{
|
||||
var ids = recordingIds.Where(x => x.IsNotNullOrWhiteSpace()).Distinct();
|
||||
var httpRequest = _requestBuilder.GetRequestBuilder().Create()
|
||||
.SetSegment("route", "search/fingerprint")
|
||||
.Build();
|
||||
|
||||
httpRequest.SetContent(ids.ToJson());
|
||||
httpRequest.Headers.ContentType = "application/json";
|
||||
httpRequest.SetContent(ids.ToJson());
|
||||
httpRequest.Headers.ContentType = "application/json";
|
||||
|
||||
var httpResponse = _httpClient.Post<List<AlbumResource>>(httpRequest);
|
||||
var httpResponse = _httpClient.Post<List<AlbumResource>>(httpRequest);
|
||||
|
||||
return httpResponse.Resource.Select(MapSearchResult)
|
||||
.Where(x => x != null)
|
||||
.ToList();
|
||||
return httpResponse.Resource.Select(MapSearchResult)
|
||||
.Where(x => x != null)
|
||||
.ToList();
|
||||
}
|
||||
catch (HttpException ex)
|
||||
{
|
||||
if (ex.Response != null && ex.Response.StatusCode == HttpStatusCode.ServiceUnavailable)
|
||||
{
|
||||
throw new SkyHookException("Search by fingerprint failed. LidarrAPI Temporarily Unavailable (503)");
|
||||
}
|
||||
|
||||
throw new SkyHookException("Search by fingerprint failed. Unable to communicate with LidarrAPI. {0}", ex, ex.Message);
|
||||
}
|
||||
catch (Exception ex) when (ex is not SkyHookException)
|
||||
{
|
||||
_logger.Warn(ex, ex.Message);
|
||||
throw new SkyHookException("Search by fingerprint failed. Invalid response received from LidarrAPI.");
|
||||
}
|
||||
}
|
||||
|
||||
public List<object> SearchForNewEntity(string title)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ namespace NzbDrone.Core.Music
|
|||
{
|
||||
public enum ArtistStatusType
|
||||
{
|
||||
Deleted = -1,
|
||||
Continuing = 0,
|
||||
Ended = 1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@ public bool UpsertMany(List<ArtistMetadata> data)
|
|||
var existing = existingMetadata.SingleOrDefault(x => x.ForeignArtistId == meta.ForeignArtistId);
|
||||
if (existing != null)
|
||||
{
|
||||
if (IsPlaceholderData(meta) && !IsPlaceholderData(existing))
|
||||
{
|
||||
_logger.Warn($"Skipping metadata downgrade: {existing.Name} -> {meta.Name}, for artist {meta.ForeignArtistId}");
|
||||
continue;
|
||||
}
|
||||
|
||||
meta.UseDbFieldsFrom(existing);
|
||||
if (!meta.Equals(existing))
|
||||
{
|
||||
|
|
@ -62,5 +68,10 @@ public bool UpsertMany(List<ArtistMetadata> data)
|
|||
|
||||
return updateMetadataList.Count > 0 || addMetadataList.Count > 0;
|
||||
}
|
||||
|
||||
private static bool IsPlaceholderData(ArtistMetadata metadata) =>
|
||||
metadata.Name?.StartsWith("Unknown Artist", System.StringComparison.OrdinalIgnoreCase) != false ||
|
||||
metadata.Disambiguation == "Artist not found in database" ||
|
||||
metadata.Type == "Unknown";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ public List<Artist> AddArtists(List<Artist> newArtists, bool doRefresh = true, b
|
|||
{
|
||||
var added = DateTime.UtcNow;
|
||||
var artistsToAdd = new List<Artist>();
|
||||
var existingArtists = _artistService.GetAllArtists();
|
||||
|
||||
foreach (var s in newArtists)
|
||||
{
|
||||
|
|
@ -84,6 +85,12 @@ public List<Artist> AddArtists(List<Artist> newArtists, bool doRefresh = true, b
|
|||
var artist = AddSkyhookData(s);
|
||||
artist = SetPropertiesAndValidate(artist);
|
||||
artist.Added = added;
|
||||
if (existingArtists.Any(f => f.ForeignArtistId == artist.ForeignArtistId))
|
||||
{
|
||||
_logger.Debug("Musicbrainz ID {0} was not added due to validation failure: Artist already exists in database", s.ForeignArtistId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (artistsToAdd.Any(f => f.ForeignArtistId == artist.ForeignArtistId))
|
||||
{
|
||||
_logger.Debug("Musicbrainz ID {0} was not added due to validation failure: Artist already exists on list", s.ForeignArtistId);
|
||||
|
|
|
|||
|
|
@ -23,19 +23,51 @@ public bool ShouldMonitorNewAlbum(Album addedAlbum, List<Album> existingAlbums,
|
|||
{
|
||||
if (monitorNewItems == NewItemMonitorTypes.None)
|
||||
{
|
||||
_logger.Trace("Album '{0}' will not be monitored: Monitor setting is set to 'None'", addedAlbum.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (monitorNewItems == NewItemMonitorTypes.All)
|
||||
{
|
||||
_logger.Trace("Album '{0}' will be monitored: Monitor setting is set to 'All'", addedAlbum.Title);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (monitorNewItems == NewItemMonitorTypes.New)
|
||||
{
|
||||
var newest = existingAlbums.MaxBy(x => x.ReleaseDate ?? DateTime.MinValue)?.ReleaseDate ?? DateTime.MinValue;
|
||||
var newestExistingDate = existingAlbums
|
||||
.Where(x => x.ReleaseDate.HasValue)
|
||||
.MaxBy(x => x.ReleaseDate.Value)?.ReleaseDate;
|
||||
|
||||
return (addedAlbum.ReleaseDate ?? DateTime.MinValue) >= newest;
|
||||
if (!addedAlbum.ReleaseDate.HasValue)
|
||||
{
|
||||
if (!newestExistingDate.HasValue)
|
||||
{
|
||||
_logger.Debug("Album '{0}' will be monitored: Both new and existing albums have no release dates", addedAlbum.Title);
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug("Album '{0}' will not be monitored: Albums without release dates are skipped when existing albums have dates", addedAlbum.Title);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!newestExistingDate.HasValue)
|
||||
{
|
||||
_logger.Debug("Album '{0}' will be monitored: No existing albums have release dates, so this is considered the first 'new' release", addedAlbum.Title);
|
||||
return true;
|
||||
}
|
||||
|
||||
var shouldMonitor = addedAlbum.ReleaseDate.Value >= newestExistingDate.Value;
|
||||
_logger.Trace("Album '{0}' ({1}) {2} be monitored: Release date is {3} the most recent existing album ({4})",
|
||||
addedAlbum.Title,
|
||||
addedAlbum.ReleaseDate.Value.ToString("yyyy-MM-dd"),
|
||||
shouldMonitor ? "will" : "will not",
|
||||
shouldMonitor ? "on or after" : "before",
|
||||
newestExistingDate.Value.ToString("yyyy-MM-dd"));
|
||||
|
||||
return shouldMonitor;
|
||||
}
|
||||
|
||||
throw new NotImplementedException($"Unknown new item monitor type {monitorNewItems}");
|
||||
|
|
|
|||
|
|
@ -87,7 +87,15 @@ protected override RemoteData GetRemoteData(Artist local, List<Artist> remote)
|
|||
}
|
||||
catch (ArtistNotFoundException)
|
||||
{
|
||||
_logger.Error($"Could not find artist with id {local.Metadata.Value.ForeignArtistId}");
|
||||
if (local.Metadata.Value.Status != ArtistStatusType.Deleted)
|
||||
{
|
||||
local.Metadata.Value.Status = ArtistStatusType.Deleted;
|
||||
_artistService.UpdateArtist(local);
|
||||
_logger.Debug("Artist marked as deleted on MusicBrainz for {0}", local.Name);
|
||||
_eventAggregator.PublishEvent(new ArtistUpdatedEvent(local));
|
||||
}
|
||||
|
||||
_logger.Error($"Artist '{local.Name}' (mbid {local.Metadata.Value.ForeignArtistId}) was not found, it may have been removed from MusicBrainz.");
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -42,9 +42,9 @@ public bool ShouldRefresh(Artist artist)
|
|||
return false;
|
||||
}
|
||||
|
||||
if (artist.Metadata.Value.Status == ArtistStatusType.Continuing && artist.LastInfoSync < DateTime.UtcNow.AddDays(-2))
|
||||
if (artist.Metadata.Value.Status != ArtistStatusType.Ended && artist.LastInfoSync < DateTime.UtcNow.AddDays(-2))
|
||||
{
|
||||
_logger.Trace("Artist {0} is continuing and has not been refreshed in 2 days, should refresh.", artist.Name);
|
||||
_logger.Trace("Artist {0} is not ended and has not been refreshed in 2 days, should refresh.", artist.Name);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -52,7 +52,7 @@ public bool ShouldRefresh(Artist artist)
|
|||
|
||||
if (lastAlbum != null && lastAlbum.ReleaseDate > DateTime.UtcNow.AddDays(-30))
|
||||
{
|
||||
_logger.Trace("Last album in {0} aired less than 30 days ago, should refresh.", artist.Name);
|
||||
_logger.Trace("Last album in {0} released less than 30 days ago, should refresh.", artist.Name);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ public void ConfigureServices(IServiceCollection services)
|
|||
b.ClearProviders();
|
||||
b.SetMinimumLevel(LogLevel.Trace);
|
||||
b.AddFilter("Microsoft.AspNetCore", LogLevel.Warning);
|
||||
b.AddFilter("Lidarr.Http.Authentication", LogLevel.Information);
|
||||
b.AddFilter("Lidarr.Http.Authentication.ApiKeyAuthenticationHandler", LogLevel.Information);
|
||||
b.AddFilter("Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager", LogLevel.Error);
|
||||
b.AddNLog();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
namespace NzbDrone.Integration.Test.ApiTests
|
||||
{
|
||||
[TestFixture]
|
||||
[Ignore("Waiting for metadata to be back again", Until = "2025-09-01 00:00:00Z")]
|
||||
[Ignore("Waiting for metadata to be back again", Until = "2025-10-01 00:00:00Z")]
|
||||
public class ArtistEditorFixture : IntegrationTest
|
||||
{
|
||||
private void GivenExistingArtist()
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
namespace NzbDrone.Integration.Test.ApiTests
|
||||
{
|
||||
[TestFixture]
|
||||
[Ignore("Waiting for metadata to be back again", Until = "2025-09-01 00:00:00Z")]
|
||||
[Ignore("Waiting for metadata to be back again", Until = "2025-10-01 00:00:00Z")]
|
||||
public class ArtistFixture : IntegrationTest
|
||||
{
|
||||
[Test]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
namespace NzbDrone.Integration.Test.ApiTests
|
||||
{
|
||||
[TestFixture]
|
||||
[Ignore("Waiting for metadata to be back again", Until = "2025-09-01 00:00:00Z")]
|
||||
[Ignore("Waiting for metadata to be back again", Until = "2025-10-01 00:00:00Z")]
|
||||
public class ArtistLookupFixture : IntegrationTest
|
||||
{
|
||||
[TestCase("Kiss", "Kiss")]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
namespace NzbDrone.Integration.Test.ApiTests
|
||||
{
|
||||
[TestFixture]
|
||||
[Ignore("Waiting for metadata to be back again", Until = "2025-09-01 00:00:00Z")]
|
||||
[Ignore("Waiting for metadata to be back again", Until = "2025-10-01 00:00:00Z")]
|
||||
public class BlocklistFixture : IntegrationTest
|
||||
{
|
||||
private ArtistResource _artist;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
namespace NzbDrone.Integration.Test.ApiTests
|
||||
{
|
||||
[TestFixture]
|
||||
[Ignore("Waiting for metadata to be back again", Until = "2025-09-01 00:00:00Z")]
|
||||
[Ignore("Waiting for metadata to be back again", Until = "2025-10-01 00:00:00Z")]
|
||||
public class CalendarFixture : IntegrationTest
|
||||
{
|
||||
public ClientBase<AlbumResource> Calendar;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
namespace NzbDrone.Integration.Test.ApiTests
|
||||
{
|
||||
[TestFixture]
|
||||
[Ignore("Waiting for metadata to be back again", Until = "2025-09-01 00:00:00Z")]
|
||||
[Ignore("Waiting for metadata to be back again", Until = "2025-10-01 00:00:00Z")]
|
||||
public class TrackFixture : IntegrationTest
|
||||
{
|
||||
private ArtistResource _artist;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
namespace NzbDrone.Integration.Test.ApiTests.WantedTests
|
||||
{
|
||||
[TestFixture]
|
||||
[Ignore("Waiting for metadata to be back again", Until = "2025-09-01 00:00:00Z")]
|
||||
[Ignore("Waiting for metadata to be back again", Until = "2025-10-01 00:00:00Z")]
|
||||
public class CutoffUnmetFixture : IntegrationTest
|
||||
{
|
||||
[SetUp]
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
namespace NzbDrone.Integration.Test.ApiTests.WantedTests
|
||||
{
|
||||
[TestFixture]
|
||||
[Ignore("Waiting for metadata to be back again", Until = "2025-09-01 00:00:00Z")]
|
||||
[Ignore("Waiting for metadata to be back again", Until = "2025-10-01 00:00:00Z")]
|
||||
public class MissingFixture : IntegrationTest
|
||||
{
|
||||
[SetUp]
|
||||
|
|
|
|||
Loading…
Reference in a new issue