New: Add {MediaInfo BestAudioCodec} naming token

Adds a new naming token that resolves to the highest quality audio
codec across all streams in a file, rather than just the primary
stream. This addresses the common issue where release groups mux the
lowest quality codec first (e.g. AC3), causing Radarr to rename files
with the wrong codec and triggering unnecessary upgrade loops.

Centralizes audio codec identification into an AudioCodec enum and
AudioCodecHelper class (following the HdrFormat pattern), eliminating
the duplicated matching logic between MediaInfoFormatter and
VideoFileInfoReader.

Also adds the token to the naming modal so users can discover it.

Fixes #6488
This commit is contained in:
Mate Herber 2026-03-04 16:31:07 +01:00
parent 89110c2cc8
commit 6b72400fc0
9 changed files with 372 additions and 106 deletions

View file

@ -143,6 +143,7 @@ const mediaInfoTokens = [
{ token: '{MediaInfo Full}', example: 'x264 DTS [EN+DE]', footNotes: '1' },
{ token: '{MediaInfo AudioCodec}', example: 'DTS' },
{ token: '{MediaInfo BestAudioCodec}', example: 'DTS-HD MA' },
{ token: '{MediaInfo AudioChannels}', example: '5.1' },
{
token: '{MediaInfo AudioLanguages}',

View file

@ -0,0 +1,106 @@
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.MediaFiles.MediaInfo;
using NzbDrone.Test.Common;
namespace NzbDrone.Core.Test.MediaFiles.MediaInfo.MediaInfoFormatterTests
{
[TestFixture]
public class FormatBestAudioCodecFixture : TestBase
{
private static string sceneName = "My.Movie.2020-Group";
[Test]
public void should_pick_truehd_atmos_over_ac3()
{
var mediaInfo = new MediaInfoModel
{
BestAudioFormat = "truehd",
BestAudioCodecID = "thd+",
BestAudioProfile = string.Empty
};
MediaInfoFormatter.FormatAudioCodec(
new MediaInfoModel
{
AudioFormat = mediaInfo.BestAudioFormat,
AudioCodecID = mediaInfo.BestAudioCodecID,
AudioProfile = mediaInfo.BestAudioProfile
},
sceneName).Should().Be("TrueHD Atmos");
}
[Test]
public void should_pick_dts_hd_ma_over_ac3()
{
var mediaInfo = new MediaInfoModel
{
BestAudioFormat = "dts",
BestAudioCodecID = string.Empty,
BestAudioProfile = "DTS-HD MA"
};
MediaInfoFormatter.FormatAudioCodec(
new MediaInfoModel
{
AudioFormat = mediaInfo.BestAudioFormat,
AudioCodecID = mediaInfo.BestAudioCodecID,
AudioProfile = mediaInfo.BestAudioProfile
},
sceneName).Should().Be("DTS-HD MA");
}
[Test]
public void should_pick_dtsx_over_dts_hd_ma()
{
var mediaInfo = new MediaInfoModel
{
BestAudioFormat = "dts",
BestAudioCodecID = string.Empty,
BestAudioProfile = "DTS:X"
};
MediaInfoFormatter.FormatAudioCodec(
new MediaInfoModel
{
AudioFormat = mediaInfo.BestAudioFormat,
AudioCodecID = mediaInfo.BestAudioCodecID,
AudioProfile = mediaInfo.BestAudioProfile
},
sceneName).Should().Be("DTS-X");
}
[Test]
public void should_return_single_stream_codec()
{
var mediaInfo = new MediaInfoModel
{
BestAudioFormat = "ac3",
BestAudioCodecID = string.Empty,
BestAudioProfile = string.Empty
};
MediaInfoFormatter.FormatAudioCodec(
new MediaInfoModel
{
AudioFormat = mediaInfo.BestAudioFormat,
AudioCodecID = mediaInfo.BestAudioCodecID,
AudioProfile = mediaInfo.BestAudioProfile
},
sceneName).Should().Be("AC3");
}
[Test]
public void should_return_null_when_best_audio_format_is_null()
{
var mediaInfo = new MediaInfoModel
{
AudioFormat = null,
AudioCodecID = null,
AudioProfile = null
};
MediaInfoFormatter.FormatAudioCodec(mediaInfo, sceneName).Should().BeNull();
}
}
}

View file

@ -775,6 +775,30 @@ public void should_not_update_media_info_if_token_configured_and_revision_is_new
Mocker.GetMock<IUpdateMediaInfo>().Verify(v => v.Update(_movieFile, _movie), Times.Never());
}
[Test]
public void should_format_best_audio_codec()
{
_movieFile.ReleaseGroup = null;
_movieFile.MediaInfo = new MediaInfoModel
{
VideoFormat = "h264",
AudioFormat = "ac3",
AudioChannels = 6,
AudioLanguages = new List<string> { "eng" },
Subtitles = new List<string> { "eng" },
BestAudioFormat = "dts",
BestAudioCodecID = string.Empty,
BestAudioProfile = "DTS-HD MA",
SchemaRevision = 15
};
_namingConfig.StandardMovieFormat = "{MediaInfo BestAudioCodec}";
Subject.BuildFileName(_movie, _movieFile)
.Should().Be("DTS-HD MA");
}
private void GivenMediaInfoModel(string videoCodec = "h264",
string audioCodec = "dts",
int audioChannels = 6,

View file

@ -0,0 +1,176 @@
namespace NzbDrone.Core.MediaFiles.MediaInfo
{
public enum AudioCodec
{
Unknown = 0,
MP2 = 1,
MP3 = 2,
PCM = 3,
Vorbis = 4,
WMA = 5,
Opus = 6,
AAC = 7,
HE_AAC = 8,
AC3 = 9,
FLAC = 10,
EAC3 = 11,
DTS = 12,
DTS_Express = 13,
DTS_9624 = 14,
DTS_ES = 15,
DTS_HD_HRA = 16,
EAC3Atmos = 17,
DTS_HD_MA = 18,
DTS_X = 19,
TrueHD = 20,
TrueHDAtmos = 21
}
public static class AudioCodecHelper
{
public static AudioCodec Resolve(string format, string codecID, string profile)
{
format = format ?? string.Empty;
codecID = codecID ?? string.Empty;
profile = profile ?? string.Empty;
if (codecID == "thd+")
{
return AudioCodec.TrueHDAtmos;
}
if (format == "truehd")
{
return AudioCodec.TrueHD;
}
if (format == "flac")
{
return AudioCodec.FLAC;
}
if (format == "dts")
{
if (profile == "DTS:X")
{
return AudioCodec.DTS_X;
}
if (profile == "DTS-HD MA")
{
return AudioCodec.DTS_HD_MA;
}
if (profile == "DTS-ES")
{
return AudioCodec.DTS_ES;
}
if (profile == "DTS-HD HRA")
{
return AudioCodec.DTS_HD_HRA;
}
if (profile == "DTS Express")
{
return AudioCodec.DTS_Express;
}
if (profile == "DTS 96/24")
{
return AudioCodec.DTS_9624;
}
return AudioCodec.DTS;
}
if (codecID == "ec+3")
{
return AudioCodec.EAC3Atmos;
}
if (format == "eac3")
{
return AudioCodec.EAC3;
}
if (format == "ac3")
{
return AudioCodec.AC3;
}
if (format == "aac")
{
if (codecID == "A_AAC/MPEG4/LC/SBR")
{
return AudioCodec.HE_AAC;
}
return AudioCodec.AAC;
}
if (format == "mp3")
{
return AudioCodec.MP3;
}
if (format == "mp2")
{
return AudioCodec.MP2;
}
if (format == "opus")
{
return AudioCodec.Opus;
}
if (format.StartsWith("pcm_") || format.StartsWith("adpcm_"))
{
return AudioCodec.PCM;
}
if (format == "vorbis")
{
return AudioCodec.Vorbis;
}
if (format == "wmav1" ||
format == "wmav2" ||
format == "wmapro")
{
return AudioCodec.WMA;
}
return AudioCodec.Unknown;
}
public static string GetDisplayName(AudioCodec codec)
{
return codec switch
{
AudioCodec.TrueHDAtmos => "TrueHD Atmos",
AudioCodec.TrueHD => "TrueHD",
AudioCodec.DTS_X => "DTS-X",
AudioCodec.DTS_HD_MA => "DTS-HD MA",
AudioCodec.DTS_HD_HRA => "DTS-HD HRA",
AudioCodec.DTS_ES => "DTS-ES",
AudioCodec.DTS_Express => "DTS Express",
AudioCodec.DTS_9624 => "DTS 96/24",
AudioCodec.DTS => "DTS",
AudioCodec.EAC3Atmos => "EAC3 Atmos",
AudioCodec.EAC3 => "EAC3",
AudioCodec.FLAC => "FLAC",
AudioCodec.AC3 => "AC3",
AudioCodec.HE_AAC => "HE-AAC",
AudioCodec.AAC => "AAC",
AudioCodec.MP3 => "MP3",
AudioCodec.MP2 => "MP2",
AudioCodec.Opus => "Opus",
AudioCodec.PCM => "PCM",
AudioCodec.Vorbis => "Vorbis",
AudioCodec.WMA => "WMA",
_ => string.Empty
};
}
}
}

View file

@ -45,111 +45,11 @@ public static string FormatAudioCodec(MediaInfoModel mediaInfo, string sceneName
}
// see definitions here https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/codec_desc.c
if (audioCodecID == "thd+")
var codec = AudioCodecHelper.Resolve(audioFormat, audioCodecID, audioProfile);
if (codec != AudioCodec.Unknown)
{
return "TrueHD Atmos";
}
if (audioFormat == "truehd")
{
return "TrueHD";
}
if (audioFormat == "flac")
{
return "FLAC";
}
if (audioFormat == "dts")
{
if (audioProfile == "DTS:X")
{
return "DTS-X";
}
if (audioProfile == "DTS-HD MA")
{
return "DTS-HD MA";
}
if (audioProfile == "DTS-ES")
{
return "DTS-ES";
}
if (audioProfile == "DTS-HD HRA")
{
return "DTS-HD HRA";
}
if (audioProfile == "DTS Express")
{
return "DTS Express";
}
if (audioProfile == "DTS 96/24")
{
return "DTS 96/24";
}
return "DTS";
}
if (audioCodecID == "ec+3")
{
return "EAC3 Atmos";
}
if (audioFormat == "eac3")
{
return "EAC3";
}
if (audioFormat == "ac3")
{
return "AC3";
}
if (audioFormat == "aac")
{
if (audioCodecID == "A_AAC/MPEG4/LC/SBR")
{
return "HE-AAC";
}
return "AAC";
}
if (audioFormat == "mp3")
{
return "MP3";
}
if (audioFormat == "mp2")
{
return "MP2";
}
if (audioFormat == "opus")
{
return "Opus";
}
if (audioFormat.StartsWith("pcm_") || audioFormat.StartsWith("adpcm_"))
{
return "PCM";
}
if (audioFormat == "vorbis")
{
return "Vorbis";
}
if (audioFormat == "wmav1" ||
audioFormat == "wmav2" ||
audioFormat == "wmapro")
{
return "WMA";
return AudioCodecHelper.GetDisplayName(codec);
}
Logger.ForDebugEvent()

View file

@ -43,6 +43,12 @@ public class MediaInfoModel : IEmbeddedDocument
public string AudioProfile { get; set; }
public string BestAudioFormat { get; set; }
public string BestAudioCodecID { get; set; }
public string BestAudioProfile { get; set; }
public long AudioBitrate { get; set; }
public TimeSpan RunTime { get; set; }

View file

@ -22,7 +22,7 @@ public class VideoFileInfoReader : IVideoFileInfoReader
private readonly List<FFProbePixelFormat> _pixelFormats;
public const int MINIMUM_MEDIA_INFO_SCHEMA_REVISION = 14;
public const int CURRENT_MEDIA_INFO_SCHEMA_REVISION = 14;
public const int CURRENT_MEDIA_INFO_SCHEMA_REVISION = 15;
private static readonly string[] ValidHdrColourPrimaries = { "bt2020" };
private static readonly string[] HlgTransferFunctions = { "arib-std-b67" };
@ -92,6 +92,11 @@ public MediaInfoModel GetMediaInfo(string filename)
mediaInfoModel.AudioCodecID = analysis.PrimaryAudioStream?.CodecTagString;
mediaInfoModel.AudioProfile = analysis.PrimaryAudioStream?.Profile;
mediaInfoModel.AudioBitrate = GetBitrate(analysis.PrimaryAudioStream);
var bestAudioStream = GetBestAudioStream(analysis.AudioStreams);
mediaInfoModel.BestAudioFormat = bestAudioStream?.CodecName;
mediaInfoModel.BestAudioCodecID = bestAudioStream?.CodecTagString;
mediaInfoModel.BestAudioProfile = bestAudioStream?.Profile;
mediaInfoModel.RunTime = GetBestRuntime(analysis.PrimaryAudioStream?.Duration, primaryVideoStream?.Duration, analysis.Format.Duration);
mediaInfoModel.AudioStreamCount = analysis.AudioStreams.Count;
mediaInfoModel.AudioChannels = analysis.PrimaryAudioStream?.Channels ?? 0;
@ -194,6 +199,40 @@ private FFProbePixelFormat GetPixelFormat(string format)
return _pixelFormats.Find(x => x.Name == format);
}
private static AudioStream GetBestAudioStream(List<AudioStream> audioStreams)
{
if (audioStreams == null || audioStreams.Count == 0)
{
return null;
}
if (audioStreams.Count == 1)
{
return audioStreams[0];
}
AudioStream best = null;
var bestRank = -1;
foreach (var stream in audioStreams)
{
var rank = GetAudioCodecRank(stream.CodecName, stream.CodecTagString, stream.Profile);
if (rank > bestRank)
{
bestRank = rank;
best = stream;
}
}
return best;
}
private static int GetAudioCodecRank(string format, string codecID, string profile)
{
return (int)AudioCodecHelper.Resolve(format, codecID, profile);
}
public static HdrFormat GetHdrFormat(int bitDepth, string colorPrimaries, string transferFunction, List<SideData> sideData)
{
if (bitDepth < 10)

View file

@ -30,6 +30,7 @@ public class FileNameBuilder : IBuildFileNames
{
private const string MediaInfoVideoDynamicRangeToken = "{MediaInfo VideoDynamicRange}";
private const string MediaInfoVideoDynamicRangeTypeToken = "{MediaInfo VideoDynamicRangeType}";
private const string MediaInfoBestAudioCodecToken = "{MediaInfo BestAudioCodec}";
private readonly INamingConfigService _namingConfigService;
private readonly IQualityDefinitionService _qualityDefinitionService;
@ -366,7 +367,8 @@ private void AddQualityTokens(Dictionary<string, Func<TokenMatch, string>> token
new Dictionary<string, int>(FileNameBuilderTokenEqualityComparer.Instance)
{
{ MediaInfoVideoDynamicRangeToken, 5 },
{ MediaInfoVideoDynamicRangeTypeToken, 13 }
{ MediaInfoVideoDynamicRangeTypeToken, 13 },
{ MediaInfoBestAudioCodecToken, 15 }
};
private void AddMediaInfoTokens(Dictionary<string, Func<TokenMatch, string>> tokenHandlers, MovieFile movieFile)
@ -415,6 +417,15 @@ private void AddMediaInfoTokens(Dictionary<string, Func<TokenMatch, string>> tok
m => MediaInfoFormatter.FormatVideoDynamicRange(movieFile.MediaInfo);
tokenHandlers[MediaInfoVideoDynamicRangeTypeToken] =
m => MediaInfoFormatter.FormatVideoDynamicRangeType(movieFile.MediaInfo);
var bestAudioMediaInfo = new MediaInfoModel
{
AudioFormat = movieFile.MediaInfo.BestAudioFormat,
AudioCodecID = movieFile.MediaInfo.BestAudioCodecID,
AudioProfile = movieFile.MediaInfo.BestAudioProfile
};
var bestAudioCodec = MediaInfoFormatter.FormatAudioCodec(bestAudioMediaInfo, sceneName) ?? string.Empty;
tokenHandlers[MediaInfoBestAudioCodecToken] = m => bestAudioCodec;
}
private void AddCustomFormats(Dictionary<string, Func<TokenMatch, string>> tokenHandlers, Movie movie, MovieFile movieFile, List<CustomFormat> customFormats = null)

View file

@ -35,6 +35,9 @@ public FileNameSampleService(IBuildFileNames buildFileNames)
AudioFormat = "DTS",
AudioChannels = 6,
AudioChannelPositions = "5.1",
BestAudioFormat = "dts",
BestAudioCodecID = string.Empty,
BestAudioProfile = "DTS-HD MA",
AudioLanguages = new List<string> { "ger" },
Subtitles = new List<string> { "eng", "ger" }
};