Add audio custom format specs and show scores in import UI

Added custom format specs for audio (sample rate, bit depth, bitrate, codec, channels) with validation and UI fields. MediaInfo is now passed to custom format evaluation. ManualImportResource and UI updated to expose and display custom format matches and scores. Improved API validation and upgrade logic to use custom format scores. SizeSpecification validator now allows Max = Min.
This commit is contained in:
Ahmed Al-Taiar 2026-01-17 12:47:13 -05:00
parent 12d7d2df15
commit 4dee096c83
15 changed files with 324 additions and 47 deletions

View file

@ -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';

View file

@ -2,6 +2,7 @@
// Please do not change this file!
interface CssExports {
'additionalFile': string;
'customFormatScore': string;
'customFormatTooltip': string;
'label': string;
'path': string;

View file

@ -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)}
</TableRowCell>
<TableRowCell>
{
customFormats?.length ?
<Popover
anchor={
<Icon name={icons.INTERACTIVE} />
}
title={translate('Formats')}
body={
<div className={styles.customFormatTooltip}>
<AlbumFormats formats={customFormats} />
</div>
}
position={tooltipPositions.LEFT}
/> :
null
}
<TableRowCell
className={styles.customFormatScore}
>
<Tooltip
anchor={formatCustomFormatScore(
customFormatScore,
customFormats?.length
)}
tooltip={<AlbumFormats formats={customFormats} />}
position={tooltipPositions.LEFT}
/>
</TableRowCell>
{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,

View file

@ -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<CustomFormatResource> Create([FromBody] CustomFormatResource
{
var model = customFormatResource.ToModel(_specifications);
Validate(model);
return Created(_formatService.Insert(model).Id);
}
@ -71,8 +83,6 @@ public ActionResult<CustomFormatResource> 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<ICustomFormatSpecification> GetPresets()
{
yield return new ReleaseTitleSpecification

View file

@ -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<CustomFormatResource> CustomFormats { get; set; }
public int CustomFormatScore { get; set; }
public int IndexerFlags { get; set; }
public IEnumerable<Rejection> 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,

View file

@ -122,6 +122,7 @@ public List<CustomFormat> 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<CustomFormat> 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);

View file

@ -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)
// {

View file

@ -0,0 +1,45 @@
using FluentValidation;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.CustomFormats
{
public class AudioBitDepthSpecificationValidator : AbstractValidator<AudioBitDepthSpecification>
{
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));
}
}
}

View file

@ -0,0 +1,45 @@
using FluentValidation;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.CustomFormats
{
public class AudioBitrateSpecificationValidator : AbstractValidator<AudioBitrateSpecification>
{
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));
}
}
}

View file

@ -0,0 +1,45 @@
using FluentValidation;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.CustomFormats
{
public class AudioChannelsSpecificationValidator : AbstractValidator<AudioChannelsSpecification>
{
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));
}
}
}

View file

@ -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<AudioCodecSpecification>
{
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));
}
}
}

View file

@ -0,0 +1,45 @@
using FluentValidation;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.CustomFormats
{
public class AudioSampleRateSpecificationValidator : AbstractValidator<AudioSampleRateSpecification>
{
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));
}
}
}

View file

@ -10,7 +10,7 @@ public class SizeSpecificationValidator : AbstractValidator<SizeSpecification>
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);
}
}

View file

@ -304,6 +304,11 @@ public List<ManualImportItem> UpdateItems(List<ManualImportItem> items)
item.Rejections = decision.Rejections;
item.Size = decision.Item.Size;
if (decision.Item.Artist != null)
{
item.CustomFormats = _formatCalculator.ParseCustomFormat(decision.Item);
}
result.Add(item);
}

View file

@ -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)");
}
}
}