diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css index 4c3b27de4..bcbfd1664 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css @@ -10,6 +10,12 @@ text-align: center; } +.customFormatScore { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + text-align: center; +} + .label { composes: label from '~Components/Label.css'; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css.d.ts b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css.d.ts index 21de4616c..c5e520d0b 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css.d.ts +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css.d.ts @@ -2,6 +2,7 @@ // Please do not change this file! interface CssExports { 'additionalFile': string; + 'customFormatScore': string; 'customFormatTooltip': string; 'label': string; 'path': string; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js index f5395ccbc..b40afed7e 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js @@ -19,6 +19,7 @@ import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal'; import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal'; import SelectTrackModal from 'InteractiveImport/Track/SelectTrackModal'; import formatBytes from 'Utilities/Number/formatBytes'; +import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import translate from 'Utilities/String/translate'; import InteractiveImportRowCellPlaceholder from './InteractiveImportRowCellPlaceholder'; @@ -185,6 +186,7 @@ class InteractiveImportRow extends Component { releaseGroup, size, customFormats, + customFormatScore, indexerFlags, rejections, columns, @@ -323,23 +325,17 @@ class InteractiveImportRow extends Component { {formatBytes(size)} - - { - customFormats?.length ? - - } - title={translate('Formats')} - body={ -
- -
- } - position={tooltipPositions.LEFT} - /> : - null - } + + } + position={tooltipPositions.LEFT} + /> {isIndexerFlagsColumnVisible ? ( @@ -462,6 +458,7 @@ InteractiveImportRow.propTypes = { quality: PropTypes.object, size: PropTypes.number.isRequired, customFormats: PropTypes.arrayOf(PropTypes.object), + customFormatScore: PropTypes.number.isRequired, indexerFlags: PropTypes.number.isRequired, rejections: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired, diff --git a/src/Lidarr.Api.V1/CustomFormats/CustomFormatController.cs b/src/Lidarr.Api.V1/CustomFormats/CustomFormatController.cs index 153952c37..4f0bf98c0 100644 --- a/src/Lidarr.Api.V1/CustomFormats/CustomFormatController.cs +++ b/src/Lidarr.Api.V1/CustomFormats/CustomFormatController.cs @@ -1,14 +1,12 @@ using System.Collections.Generic; using System.Linq; using FluentValidation; -using FluentValidation.Results; using Lidarr.Http; using Lidarr.Http.REST; using Lidarr.Http.REST.Attributes; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; using NzbDrone.Core.CustomFormats; -using NzbDrone.Core.Validation; namespace Lidarr.Api.V1.CustomFormats { @@ -39,6 +37,22 @@ public CustomFormatController(ICustomFormatService formatService, { context.AddFailure("Condition name(s) cannot be empty or consist of only spaces"); } + + // Validate each specification's internal rules + var model = customFormat.ToModel(_specifications); + for (var i = 0; i < model.Specifications.Count; i++) + { + var spec = model.Specifications[i]; + var specValidationResult = spec.Validate(); + + if (!specValidationResult.IsValid) + { + foreach (var error in specValidationResult.Errors) + { + context.AddFailure($"Specifications[{i}].{error.PropertyName}", error.ErrorMessage); + } + } + } }); } @@ -60,8 +74,6 @@ public ActionResult Create([FromBody] CustomFormatResource { var model = customFormatResource.ToModel(_specifications); - Validate(model); - return Created(_formatService.Insert(model).Id); } @@ -71,8 +83,6 @@ public ActionResult Update([FromBody] CustomFormatResource { var model = resource.ToModel(_specifications); - Validate(model); - _formatService.Update(model); return Accepted(model.Id); @@ -130,24 +140,6 @@ public object GetTemplates() return schema; } - private void Validate(CustomFormat definition) - { - foreach (var validationResult in definition.Specifications.Select(spec => spec.Validate())) - { - VerifyValidationResult(validationResult); - } - } - - private void VerifyValidationResult(ValidationResult validationResult) - { - var result = new NzbDroneValidationResult(validationResult.Errors); - - if (!result.IsValid) - { - throw new ValidationException(result.Errors); - } - } - private IEnumerable GetPresets() { yield return new ReleaseTitleSpecification diff --git a/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs b/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs index b2f70eb3f..662b637ea 100644 --- a/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs +++ b/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs @@ -2,6 +2,7 @@ using System.Linq; using Lidarr.Api.V1.Albums; using Lidarr.Api.V1.Artist; +using Lidarr.Api.V1.CustomFormats; using Lidarr.Api.V1.Tracks; using Lidarr.Http.REST; using NzbDrone.Core.DecisionEngine; @@ -24,6 +25,8 @@ public class ManualImportResource : RestResource public string ReleaseGroup { get; set; } public int QualityWeight { get; set; } public string DownloadId { get; set; } + public List CustomFormats { get; set; } + public int CustomFormatScore { get; set; } public int IndexerFlags { get; set; } public IEnumerable Rejections { get; set; } public ParsedTrackInfo AudioTags { get; set; } @@ -41,6 +44,8 @@ public static ManualImportResource ToResource(this ManualImportItem model) return null; } + var customFormatScore = model.Artist?.QualityProfile?.Value?.CalculateCustomFormatScore(model.CustomFormats) ?? 0; + return new ManualImportResource { Id = model.Id, @@ -56,6 +61,8 @@ public static ManualImportResource ToResource(this ManualImportItem model) // QualityWeight DownloadId = model.DownloadId, + CustomFormats = model.CustomFormats.ToResource(false), + CustomFormatScore = customFormatScore, IndexerFlags = model.IndexerFlags, Rejections = model.Rejections, diff --git a/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs b/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs index 0eb14fef0..87ed194c0 100644 --- a/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs +++ b/src/NzbDrone.Core/CustomFormats/CustomFormatCalculationService.cs @@ -122,6 +122,7 @@ public List ParseCustomFormat(LocalTrack localTrack) Size = localTrack.Size, Filename = Path.GetFileName(localTrack.Path), IndexerFlags = localTrack.IndexerFlags, + MediaInfo = localTrack.FileTrackInfo?.MediaInfo }; return ParseCustomFormat(input); @@ -189,7 +190,8 @@ private List ParseCustomFormat(TrackFile trackFile, Artist artist, Artist = artist, Size = trackFile.Size, IndexerFlags = trackFile.IndexerFlags, - Filename = Path.GetFileName(trackFile.Path) + Filename = Path.GetFileName(trackFile.Path), + MediaInfo = trackFile.MediaInfo }; return ParseCustomFormat(input, allCustomFormats); diff --git a/src/NzbDrone.Core/CustomFormats/CustomFormatInput.cs b/src/NzbDrone.Core/CustomFormats/CustomFormatInput.cs index f43003b47..0cf975cb5 100644 --- a/src/NzbDrone.Core/CustomFormats/CustomFormatInput.cs +++ b/src/NzbDrone.Core/CustomFormats/CustomFormatInput.cs @@ -10,6 +10,7 @@ public class CustomFormatInput public long Size { get; set; } public IndexerFlags IndexerFlags { get; set; } public string Filename { get; set; } + public MediaInfoModel MediaInfo { get; set; } // public CustomFormatInput(ParsedEpisodeInfo episodeInfo, Series series) // { diff --git a/src/NzbDrone.Core/CustomFormats/Specifications/AudioBitDepthSpecification.cs b/src/NzbDrone.Core/CustomFormats/Specifications/AudioBitDepthSpecification.cs new file mode 100644 index 000000000..9b58eac16 --- /dev/null +++ b/src/NzbDrone.Core/CustomFormats/Specifications/AudioBitDepthSpecification.cs @@ -0,0 +1,45 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.CustomFormats +{ + public class AudioBitDepthSpecificationValidator : AbstractValidator + { + public AudioBitDepthSpecificationValidator() + { + RuleFor(c => c.Min).GreaterThanOrEqualTo(0); + RuleFor(c => c.Max).GreaterThanOrEqualTo(c => c.Min); + } + } + + public class AudioBitDepthSpecification : CustomFormatSpecificationBase + { + private static readonly AudioBitDepthSpecificationValidator Validator = new (); + + public override int Order => 10; + public override string ImplementationName => "Audio Bit Depth"; + + [FieldDefinition(1, Label = "Minimum Bit Depth", HelpText = "Minimum bit depth (e.g., 16 for CD quality, 24 for Hi-Res)", Type = FieldType.Number)] + public int Min { get; set; } + + [FieldDefinition(2, Label = "Maximum Bit Depth", HelpText = "Maximum bit depth", Type = FieldType.Number)] + public int Max { get; set; } + + protected override bool IsSatisfiedByWithoutNegate(CustomFormatInput input) + { + if (input.MediaInfo == null) + { + return false; + } + + var bitDepth = input.MediaInfo.AudioBits; + return bitDepth >= Min && bitDepth <= Max; + } + + public override NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/CustomFormats/Specifications/AudioBitrateSpecification.cs b/src/NzbDrone.Core/CustomFormats/Specifications/AudioBitrateSpecification.cs new file mode 100644 index 000000000..9c4f74097 --- /dev/null +++ b/src/NzbDrone.Core/CustomFormats/Specifications/AudioBitrateSpecification.cs @@ -0,0 +1,45 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.CustomFormats +{ + public class AudioBitrateSpecificationValidator : AbstractValidator + { + public AudioBitrateSpecificationValidator() + { + RuleFor(c => c.Min).GreaterThanOrEqualTo(0); + RuleFor(c => c.Max).GreaterThanOrEqualTo(c => c.Min); + } + } + + public class AudioBitrateSpecification : CustomFormatSpecificationBase + { + private static readonly AudioBitrateSpecificationValidator Validator = new (); + + public override int Order => 11; + public override string ImplementationName => "Audio Bitrate"; + + [FieldDefinition(1, Label = "Minimum Bitrate", HelpText = "Minimum bitrate in kbps (e.g., 320 for MP3)", Type = FieldType.Number)] + public int Min { get; set; } + + [FieldDefinition(2, Label = "Maximum Bitrate", HelpText = "Maximum bitrate in kbps", Type = FieldType.Number)] + public int Max { get; set; } + + protected override bool IsSatisfiedByWithoutNegate(CustomFormatInput input) + { + if (input.MediaInfo == null) + { + return false; + } + + var bitrate = input.MediaInfo.AudioBitrate; + return bitrate >= Min && bitrate <= Max; + } + + public override NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/CustomFormats/Specifications/AudioChannelsSpecification.cs b/src/NzbDrone.Core/CustomFormats/Specifications/AudioChannelsSpecification.cs new file mode 100644 index 000000000..e91c726d2 --- /dev/null +++ b/src/NzbDrone.Core/CustomFormats/Specifications/AudioChannelsSpecification.cs @@ -0,0 +1,45 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.CustomFormats +{ + public class AudioChannelsSpecificationValidator : AbstractValidator + { + public AudioChannelsSpecificationValidator() + { + RuleFor(c => c.Min).GreaterThanOrEqualTo(0); + RuleFor(c => c.Max).GreaterThanOrEqualTo(c => c.Min); + } + } + + public class AudioChannelsSpecification : CustomFormatSpecificationBase + { + private static readonly AudioChannelsSpecificationValidator Validator = new (); + + public override int Order => 13; + public override string ImplementationName => "Audio Channels"; + + [FieldDefinition(1, Label = "Minimum Channels", HelpText = "Minimum number of audio channels (e.g., 2 for stereo, 6 for 5.1)", Type = FieldType.Number)] + public int Min { get; set; } + + [FieldDefinition(2, Label = "Maximum Channels", HelpText = "Maximum number of audio channels", Type = FieldType.Number)] + public int Max { get; set; } + + protected override bool IsSatisfiedByWithoutNegate(CustomFormatInput input) + { + if (input.MediaInfo == null) + { + return false; + } + + var channels = input.MediaInfo.AudioChannels; + return channels >= Min && channels <= Max; + } + + public override NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/CustomFormats/Specifications/AudioCodecSpecification.cs b/src/NzbDrone.Core/CustomFormats/Specifications/AudioCodecSpecification.cs new file mode 100644 index 000000000..b3527d448 --- /dev/null +++ b/src/NzbDrone.Core/CustomFormats/Specifications/AudioCodecSpecification.cs @@ -0,0 +1,61 @@ +using System.Text.RegularExpressions; +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.CustomFormats +{ + public class AudioCodecSpecificationValidator : AbstractValidator + { + public AudioCodecSpecificationValidator() + { + RuleFor(c => c.Value).NotEmpty().WithMessage("Audio Codec must not be empty"); + } + } + + public class AudioCodecSpecification : CustomFormatSpecificationBase + { + private static readonly AudioCodecSpecificationValidator Validator = new (); + + protected Regex _regex; + protected string _raw; + + public override int Order => 12; + public override string ImplementationName => "Audio Codec"; + + [FieldDefinition(1, Label = "Audio Codec", HelpText = "Codec name or regex pattern (e.g., FLAC, MP3, AAC, ALAC, OPUS, WavPack, APE)", Type = FieldType.Textbox)] + public string Value + { + get => _raw; + set + { + _raw = value; + + if (!string.IsNullOrWhiteSpace(value)) + { + _regex = new Regex(value, RegexOptions.Compiled | RegexOptions.IgnoreCase); + } + } + } + + protected override bool IsSatisfiedByWithoutNegate(CustomFormatInput input) + { + if (input.MediaInfo == null || string.IsNullOrWhiteSpace(input.MediaInfo.AudioFormat)) + { + return false; + } + + if (_regex == null) + { + return false; + } + + return _regex.IsMatch(input.MediaInfo.AudioFormat); + } + + public override NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/CustomFormats/Specifications/AudioSampleRateSpecification.cs b/src/NzbDrone.Core/CustomFormats/Specifications/AudioSampleRateSpecification.cs new file mode 100644 index 000000000..af18c07a9 --- /dev/null +++ b/src/NzbDrone.Core/CustomFormats/Specifications/AudioSampleRateSpecification.cs @@ -0,0 +1,45 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.CustomFormats +{ + public class AudioSampleRateSpecificationValidator : AbstractValidator + { + public AudioSampleRateSpecificationValidator() + { + RuleFor(c => c.Min).GreaterThanOrEqualTo(0); + RuleFor(c => c.Max).GreaterThanOrEqualTo(c => c.Min); + } + } + + public class AudioSampleRateSpecification : CustomFormatSpecificationBase + { + private static readonly AudioSampleRateSpecificationValidator Validator = new (); + + public override int Order => 9; + public override string ImplementationName => "Audio Sample Rate"; + + [FieldDefinition(1, Label = "Minimum Sample Rate", HelpText = "Minimum sample rate in Hz (e.g., 44100 for CD quality, 192000 for Hi-Res)", Type = FieldType.Number)] + public int Min { get; set; } + + [FieldDefinition(2, Label = "Maximum Sample Rate", HelpText = "Maximum sample rate in Hz", Type = FieldType.Number)] + public int Max { get; set; } + + protected override bool IsSatisfiedByWithoutNegate(CustomFormatInput input) + { + if (input.MediaInfo == null) + { + return false; + } + + var sampleRate = input.MediaInfo.AudioSampleRate; + return sampleRate >= Min && sampleRate <= Max; + } + + public override NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/CustomFormats/Specifications/SizeSpecification.cs b/src/NzbDrone.Core/CustomFormats/Specifications/SizeSpecification.cs index 9e2fe766e..45fe3bdbf 100644 --- a/src/NzbDrone.Core/CustomFormats/Specifications/SizeSpecification.cs +++ b/src/NzbDrone.Core/CustomFormats/Specifications/SizeSpecification.cs @@ -10,7 +10,7 @@ public class SizeSpecificationValidator : AbstractValidator public SizeSpecificationValidator() { RuleFor(c => c.Min).GreaterThanOrEqualTo(0); - RuleFor(c => c.Max).GreaterThan(c => c.Min); + RuleFor(c => c.Max).GreaterThanOrEqualTo(c => c.Min); RuleFor(c => c.Max).LessThanOrEqualTo(double.MaxValue); } } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs index fb35fa4a8..3adc61435 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs @@ -304,6 +304,11 @@ public List UpdateItems(List items) item.Rejections = decision.Rejections; item.Size = decision.Item.Size; + if (decision.Item.Artist != null) + { + item.CustomFormats = _formatCalculator.ParseCustomFormat(decision.Item); + } + result.Add(item); } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/UpgradeSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/UpgradeSpecification.cs index 268f627a9..641aad5ba 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/UpgradeSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/UpgradeSpecification.cs @@ -34,6 +34,11 @@ public Decision IsSatisfiedBy(LocalTrack localTrack, DownloadClientItem download var downloadPropersAndRepacks = _configService.DownloadPropersAndRepacks; var qualityComparer = new QualityModelComparer(localTrack.Artist.QualityProfile); + var qualityProfile = localTrack.Artist.QualityProfile.Value; + + // Calculate custom formats for the new track + var newCustomFormats = _customFormatCalculationService.ParseCustomFormat(localTrack); + var newFormatScore = qualityProfile.CalculateCustomFormatScore(newCustomFormats); foreach (var track in localTrack.Tracks.Where(e => e.TrackFileId > 0)) { @@ -53,11 +58,31 @@ public Decision IsSatisfiedBy(LocalTrack localTrack, DownloadClientItem download return Decision.Reject("Not an upgrade for existing track file(s). New Quality is {0}", localTrack.Quality.Quality); } - if (qualityCompare == 0 && downloadPropersAndRepacks != ProperDownloadTypes.DoNotPrefer && - localTrack.Quality.Revision.CompareTo(trackFile.Quality.Revision) < 0) + // When quality is equal, compare custom format scores + if (qualityCompare == 0) { - _logger.Debug("This file isn't a quality upgrade for all tracks. Skipping {0}", localTrack.Path); - return Decision.Reject("Not an upgrade for existing track file(s)"); + var existingCustomFormats = _customFormatCalculationService.ParseCustomFormat(trackFile, localTrack.Artist); + var existingFormatScore = qualityProfile.CalculateCustomFormatScore(existingCustomFormats); + + if (newFormatScore < existingFormatScore) + { + _logger.Debug("This file isn't a custom format upgrade. Existing: {0} [{1}], New: {2} [{3}]. Skipping {4}", + existingFormatScore, + string.Join(", ", existingCustomFormats.Select(x => x.Name)), + newFormatScore, + string.Join(", ", newCustomFormats.Select(x => x.Name)), + localTrack.Path); + return Decision.Reject("Not a custom format upgrade for existing track file(s). Existing score: {0}, New score: {1}", + existingFormatScore, + newFormatScore); + } + + if (downloadPropersAndRepacks != ProperDownloadTypes.DoNotPrefer && + localTrack.Quality.Revision.CompareTo(trackFile.Quality.Revision) < 0) + { + _logger.Debug("This file isn't a quality upgrade for all tracks. Skipping {0}", localTrack.Path); + return Decision.Reject("Not an upgrade for existing track file(s)"); + } } }