diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs
index de42c67d3..56e955ada 100644
--- a/MediaBrowser.Controller/Entities/Video.cs
+++ b/MediaBrowser.Controller/Entities/Video.cs
@@ -97,6 +97,12 @@ namespace MediaBrowser.Controller.Entities
/// The subtitle paths.
public string[] SubtitleFiles { get; set; }
+ ///
+ /// Gets or sets the audio paths.
+ ///
+ /// The audio paths.
+ public string[] AudioFiles { get; set; }
+
///
/// Gets or sets a value indicating whether this instance has subtitles.
///
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index 5715194b8..5326ecd20 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -678,6 +678,12 @@ namespace MediaBrowser.Controller.MediaEncoding
arg.Append("-i ")
.Append(GetInputPathArgument(state));
+ if (state.AudioStream.IsExternal)
+ {
+ arg.Append(" -i ")
+ .Append(string.Format(CultureInfo.InvariantCulture, "file:\"{0}\"", state.AudioStream.Path));
+ }
+
if (state.SubtitleStream != null
&& state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode
&& state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream)
@@ -1999,10 +2005,17 @@ namespace MediaBrowser.Controller.MediaEncoding
if (state.AudioStream != null)
{
- args += string.Format(
- CultureInfo.InvariantCulture,
- " -map 0:{0}",
- state.AudioStream.Index);
+ if (state.AudioStream.IsExternal)
+ {
+ args += " -map 1:a";
+ }
+ else
+ {
+ args += string.Format(
+ CultureInfo.InvariantCulture,
+ " -map 0:{0}",
+ state.AudioStream.Index);
+ }
}
else
{
diff --git a/MediaBrowser.Providers/MediaInfo/AudioResolver.cs b/MediaBrowser.Providers/MediaInfo/AudioResolver.cs
new file mode 100644
index 000000000..fce2fa551
--- /dev/null
+++ b/MediaBrowser.Providers/MediaInfo/AudioResolver.cs
@@ -0,0 +1,275 @@
+#nullable disable
+
+#pragma warning disable CA1002, CS1591
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.MediaInfo;
+
+namespace MediaBrowser.Providers.MediaInfo
+{
+ public class AudioResolver
+ {
+ private readonly ILocalizationManager _localization;
+
+ private readonly IMediaEncoder _mediaEncoder;
+
+ private readonly CancellationToken _cancellationToken;
+
+ public AudioResolver(ILocalizationManager localization, IMediaEncoder mediaEncoder, CancellationToken cancellationToken = default)
+ {
+ _localization = localization;
+ _mediaEncoder = mediaEncoder;
+ _cancellationToken = cancellationToken;
+ }
+
+ public List GetExternalAudioStreams(
+ Video video,
+ int startIndex,
+ IDirectoryService directoryService,
+ bool clearCache)
+ {
+ var streams = new List();
+
+ if (!video.IsFileProtocol)
+ {
+ return streams;
+ }
+
+ AddExternalAudioStreams(streams, video.ContainingFolderPath, video.Path, startIndex, directoryService, clearCache);
+
+ startIndex += streams.Count;
+
+ string folder = video.GetInternalMetadataPath();
+
+ if (!Directory.Exists(folder))
+ {
+ return streams;
+ }
+
+ try
+ {
+ AddExternalAudioStreams(streams, folder, video.Path, startIndex, directoryService, clearCache);
+ }
+ catch (IOException)
+ {
+ }
+
+ return streams;
+ }
+
+ public IEnumerable GetExternalAudioFiles(
+ Video video,
+ IDirectoryService directoryService,
+ bool clearCache)
+ {
+ if (!video.IsFileProtocol)
+ {
+ yield break;
+ }
+
+ var streams = GetExternalAudioStreams(video, 0, directoryService, clearCache);
+
+ foreach (var stream in streams)
+ {
+ yield return stream.Path;
+ }
+ }
+
+ public void AddExternalAudioStreams(
+ List streams,
+ string videoPath,
+ int startIndex,
+ IReadOnlyList files)
+ {
+ var videoFileNameWithoutExtension = NormalizeFilenameForAudioComparison(videoPath);
+
+ for (var i = 0; i < files.Count; i++)
+ {
+
+ var fullName = files[i];
+ var extension = Path.GetExtension(fullName.AsSpan());
+ if (!IsAudioExtension(extension))
+ {
+ continue;
+ }
+
+ Model.MediaInfo.MediaInfo mediaInfo = GetMediaInfo(fullName).Result;
+ MediaStream mediaStream = mediaInfo.MediaStreams.First();
+ mediaStream.Index = startIndex++;
+ mediaStream.Type = MediaStreamType.Audio;
+ mediaStream.IsExternal = true;
+ mediaStream.Path = fullName;
+ mediaStream.IsDefault = false;
+ mediaStream.Title = null;
+
+ var fileNameWithoutExtension = NormalizeFilenameForAudioComparison(fullName);
+
+ // The audio filename must either be equal to the video filename or start with the video filename followed by a dot
+ if (videoFileNameWithoutExtension.Equals(fileNameWithoutExtension, StringComparison.OrdinalIgnoreCase))
+ {
+ mediaStream.Path = fullName;
+ }
+ else if (fileNameWithoutExtension.Length > videoFileNameWithoutExtension.Length
+ && fileNameWithoutExtension[videoFileNameWithoutExtension.Length] == '.'
+ && fileNameWithoutExtension.StartsWith(videoFileNameWithoutExtension, StringComparison.OrdinalIgnoreCase))
+ {
+
+ // Support xbmc naming conventions - 300.spanish.m4a
+ var languageSpan = fileNameWithoutExtension;
+ while (languageSpan.Length > 0)
+ {
+ var lastDot = languageSpan.LastIndexOf('.');
+ var currentSlice = languageSpan[lastDot..];
+ languageSpan = languageSpan[(lastDot + 1)..];
+ break;
+ }
+
+ // Try to translate to three character code
+ // Be flexible and check against both the full and three character versions
+ var language = languageSpan.ToString();
+ var culture = _localization.FindLanguageInfo(language);
+
+ language = culture == null ? language : culture.ThreeLetterISOLanguageName;
+ mediaStream.Language = language;
+ }
+ else
+ {
+ continue;
+ }
+
+ mediaStream.Codec = extension.TrimStart('.').ToString().ToLowerInvariant();
+
+ streams.Add(mediaStream);
+ }
+ }
+
+ private static bool IsAudioExtension(ReadOnlySpan extension)
+ {
+ String[] audioExtensions = new[]
+ {
+ ".nsv",
+ ".m4a",
+ ".flac",
+ ".aac",
+ ".strm",
+ ".pls",
+ ".rm",
+ ".mpa",
+ ".wav",
+ ".wma",
+ ".ogg",
+ ".opus",
+ ".mp3",
+ ".mp2",
+ ".mod",
+ ".amf",
+ ".669",
+ ".dmf",
+ ".dsm",
+ ".far",
+ ".gdm",
+ ".imf",
+ ".it",
+ ".m15",
+ ".med",
+ ".okt",
+ ".s3m",
+ ".stm",
+ ".sfx",
+ ".ult",
+ ".uni",
+ ".xm",
+ ".sid",
+ ".ac3",
+ ".dts",
+ ".cue",
+ ".aif",
+ ".aiff",
+ ".ape",
+ ".mac",
+ ".mpc",
+ ".mp+",
+ ".mpp",
+ ".shn",
+ ".wv",
+ ".nsf",
+ ".spc",
+ ".gym",
+ ".adplug",
+ ".adx",
+ ".dsp",
+ ".adp",
+ ".ymf",
+ ".ast",
+ ".afc",
+ ".hps",
+ ".xsp",
+ ".acc",
+ ".m4b",
+ ".oga",
+ ".dsf",
+ ".mka"
+ };
+
+ foreach (String audioExtension in audioExtensions)
+ {
+ if (extension.Equals(audioExtension, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private Task GetMediaInfo(string path)
+ {
+ _cancellationToken.ThrowIfCancellationRequested();
+
+ return _mediaEncoder.GetMediaInfo(
+ new MediaInfoRequest
+ {
+ MediaType = DlnaProfileType.Audio,
+ MediaSource = new MediaSourceInfo
+ {
+ Path = path,
+ Protocol = MediaProtocol.File
+ }
+ },
+ _cancellationToken);
+ }
+
+ private static ReadOnlySpan NormalizeFilenameForAudioComparison(string filename)
+ {
+ // Try to account for sloppy file naming
+ filename = filename.Replace("_", string.Empty, StringComparison.Ordinal);
+ filename = filename.Replace(" ", string.Empty, StringComparison.Ordinal);
+ return Path.GetFileNameWithoutExtension(filename.AsSpan());
+ }
+
+ private void AddExternalAudioStreams(
+ List streams,
+ string folder,
+ string videoPath,
+ int startIndex,
+ IDirectoryService directoryService,
+ bool clearCache)
+ {
+ var files = directoryService.GetFilePaths(folder, clearCache, true);
+
+ AddExternalAudioStreams(streams, videoPath, startIndex, files);
+ }
+ }
+}
diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs
index d4b5d8655..1e7fcf2d2 100644
--- a/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs
+++ b/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs
@@ -50,6 +50,8 @@ namespace MediaBrowser.Providers.MediaInfo
private readonly IMediaSourceManager _mediaSourceManager;
private readonly SubtitleResolver _subtitleResolver;
+ private readonly AudioResolver _audioResolver;
+
private readonly Task _cachedTask = Task.FromResult(ItemUpdateType.None);
public FFProbeProvider(
@@ -78,6 +80,7 @@ namespace MediaBrowser.Providers.MediaInfo
_mediaSourceManager = mediaSourceManager;
_subtitleResolver = new SubtitleResolver(BaseItem.LocalizationManager);
+ _audioResolver = new AudioResolver(BaseItem.LocalizationManager, mediaEncoder);
}
public string Name => "ffprobe";
@@ -111,6 +114,14 @@ namespace MediaBrowser.Providers.MediaInfo
return true;
}
+ if (item.SupportsLocalMetadata && video != null && !video.IsPlaceHolder
+ && !video.AudioFiles.SequenceEqual(
+ _audioResolver.GetExternalAudioFiles(video, directoryService, false), StringComparer.Ordinal))
+ {
+ _logger.LogDebug("Refreshing {0} due to external audio change.", item.Path);
+ return true;
+ }
+
return false;
}
diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs
index 4ab15f60e..8db095416 100644
--- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs
+++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs
@@ -214,6 +214,8 @@ namespace MediaBrowser.Providers.MediaInfo
await AddExternalSubtitles(video, mediaStreams, options, cancellationToken).ConfigureAwait(false);
+ AddExternalAudio(video, mediaStreams, options, cancellationToken);
+
var libraryOptions = _libraryManager.GetLibraryOptions(video);
if (mediaInfo != null)
@@ -574,6 +576,29 @@ namespace MediaBrowser.Providers.MediaInfo
currentStreams.AddRange(externalSubtitleStreams);
}
+ ///
+ /// Adds the external audio.
+ ///
+ /// The video.
+ /// The current streams.
+ /// The refreshOptions.
+ /// The cancellation token.
+ private void AddExternalAudio(
+ Video video,
+ List currentStreams,
+ MetadataRefreshOptions options,
+ CancellationToken cancellationToken)
+ {
+ var audioResolver = new AudioResolver(_localization, _mediaEncoder, cancellationToken);
+
+ var startIndex = currentStreams.Count == 0 ? 0 : (currentStreams.Select(i => i.Index).Max() + 1);
+ var externalAudioStreams = audioResolver.GetExternalAudioStreams(video, startIndex, options.DirectoryService, false);
+
+ video.AudioFiles = externalAudioStreams.Select(i => i.Path).ToArray();
+
+ currentStreams.AddRange(externalAudioStreams);
+ }
+
///
/// Creates dummy chapters.
///