222 lines
7.8 KiB
C#
222 lines
7.8 KiB
C#
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using EmbyToolbox.Models;
|
||
|
||
namespace EmbyToolbox.Services;
|
||
|
||
/// <summary>Поиск внешних аудио и субтитров рядом с видеофайлом; для контейнеров с несколькими аудиопотоками — ffprobe.</summary>
|
||
public sealed class SidecarDiscoveryService
|
||
{
|
||
private static readonly StringComparer IC = StringComparer.OrdinalIgnoreCase;
|
||
|
||
private static readonly HashSet<string> AudioExts = new(IC)
|
||
{
|
||
".mka", ".mkv", ".mp4", ".m4a",
|
||
".ac3", ".eac3", ".aac", ".dts", ".flac", ".wav", ".opus", ".ogg", ".mp3", ".wma", ".aiff", ".aif", ".m4b", ".m4r"
|
||
};
|
||
|
||
/// <summary>Расширения: внутри файла может быть несколько аудиопотоков → полный ffprobe.</summary>
|
||
private static readonly HashSet<string> MultiStreamAudioProbeExts = new(IC)
|
||
{
|
||
".mka", ".mkv", ".mp4", ".m4a"
|
||
};
|
||
|
||
private static readonly HashSet<string> SubExts = new(IC)
|
||
{
|
||
".srt", ".ass", ".ssa", ".vtt", ".sub", ".idx", ".sup", ".smi"
|
||
};
|
||
|
||
private static readonly HashSet<string> FontExts = new(IC)
|
||
{
|
||
".ttf", ".otf", ".ttc", ".otc"
|
||
};
|
||
|
||
private readonly LoggingService? _logging;
|
||
|
||
public SidecarDiscoveryService(LoggingService? logging = null) => _logging = logging;
|
||
|
||
public async Task<SidecarDiscoveryResult> DiscoverAsync(
|
||
string videoPath,
|
||
FfprobeService ffprobe,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(videoPath) || !File.Exists(videoPath))
|
||
{
|
||
return new SidecarDiscoveryResult(Array.Empty<SidecarFile>(), Array.Empty<ExternalAudioFile>());
|
||
}
|
||
|
||
var dir = Path.GetDirectoryName(videoPath);
|
||
if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir))
|
||
{
|
||
return new SidecarDiscoveryResult(Array.Empty<SidecarFile>(), Array.Empty<ExternalAudioFile>());
|
||
}
|
||
|
||
var baseName = Path.GetFileNameWithoutExtension(videoPath);
|
||
var full = Path.GetFullPath(videoPath);
|
||
var list = new List<SidecarFile>();
|
||
|
||
foreach (var path in Directory.EnumerateFiles(dir))
|
||
{
|
||
if (string.Equals(path, full, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var nameNoExt = Path.GetFileNameWithoutExtension(path);
|
||
if (!string.Equals(nameNoExt, baseName, StringComparison.OrdinalIgnoreCase)
|
||
&& !nameNoExt.StartsWith(baseName + ".", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var ext = Path.GetExtension(path);
|
||
if (AudioExts.Contains(ext))
|
||
{
|
||
list.Add(new SidecarFile(path, isAudio: true, isSubtitle: false));
|
||
}
|
||
else if (SubExts.Contains(ext))
|
||
{
|
||
list.Add(new SidecarFile(path, isAudio: false, isSubtitle: true));
|
||
}
|
||
}
|
||
|
||
foreach (var subDir in Directory.EnumerateDirectories(dir))
|
||
{
|
||
var name = Path.GetFileName(subDir);
|
||
if (!name.Equals("font", StringComparison.OrdinalIgnoreCase)
|
||
&& !name.Equals("fonts", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
IEnumerable<string> fontFiles;
|
||
try
|
||
{
|
||
fontFiles = Directory.EnumerateFiles(subDir, "*.*", SearchOption.AllDirectories);
|
||
}
|
||
catch
|
||
{
|
||
continue;
|
||
}
|
||
|
||
foreach (var f in fontFiles)
|
||
{
|
||
if (!FontExts.Contains(Path.GetExtension(f)))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
list.Add(new SidecarFile(f, isAudio: false, isSubtitle: false, isFont: true));
|
||
}
|
||
}
|
||
|
||
var sorted = list.OrderBy(s => s.FileName, IC).ToList();
|
||
|
||
var externalAudioByPath = new Dictionary<string, ExternalAudioFile>(IC);
|
||
var audioPaths = sorted.Where(s => s.IsAudio).Select(s => s.FullPath).Distinct(IC).OrderBy(Path.GetFileName, IC).ToList();
|
||
|
||
foreach (var audioPath in audioPaths)
|
||
{
|
||
var streams = MultiStreamAudioProbeExts.Contains(Path.GetExtension(audioPath))
|
||
? await ProbeExternalAudioStreamsAsync(ffprobe, audioPath, cancellationToken).ConfigureAwait(false)
|
||
: CreateSingleFallbackStream(audioPath);
|
||
externalAudioByPath[audioPath] = new ExternalAudioFile(audioPath, streams);
|
||
}
|
||
|
||
var externalsOrdered = audioPaths.Select(p => externalAudioByPath[p]).ToList();
|
||
|
||
return new SidecarDiscoveryResult(sorted, externalsOrdered);
|
||
}
|
||
|
||
private async Task<IReadOnlyList<ExternalAudioStream>> ProbeExternalAudioStreamsAsync(
|
||
FfprobeService ffprobe,
|
||
string audioPath,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
var result = await ffprobe.AnalyzeAsync(audioPath, cancellationToken).ConfigureAwait(false);
|
||
if (!result.IsSuccess || string.IsNullOrWhiteSpace(result.Json))
|
||
{
|
||
_logging?.Warning($"ffprobe sidecar аудио: {result.Error ?? "нет данных"} — {audioPath}", "conversion.sidecar");
|
||
return CreateSingleFallbackStream(audioPath);
|
||
}
|
||
|
||
var media = MediaAnalysisParser.TryParse(result.Json);
|
||
var audios = media?.AudioStreams?.OrderBy(a => a.Index).ToList();
|
||
if (audios is not { Count: > 0 })
|
||
{
|
||
return CreateSingleFallbackStream(audioPath);
|
||
}
|
||
|
||
var ordinal = 0;
|
||
var list = new List<ExternalAudioStream>(audios.Count);
|
||
foreach (var a in audios)
|
||
{
|
||
list.Add(
|
||
new ExternalAudioStream
|
||
{
|
||
FileFullPath = audioPath,
|
||
StreamOrdinal = ordinal++,
|
||
CodecName = string.IsNullOrWhiteSpace(a.CodecName) ? "?" : a.CodecName,
|
||
TitleFromProbe = string.IsNullOrWhiteSpace(a.Title) ? null : a.Title.Trim(),
|
||
Channels = a.Channels,
|
||
SampleRateHz = a.SampleRateHz,
|
||
BitRateBps = a.BitRateBps
|
||
});
|
||
}
|
||
|
||
return list;
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
throw;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logging?.Warning($"ffprobe sidecar аудио: {ex.Message} — {audioPath}", "conversion.sidecar");
|
||
return CreateSingleFallbackStream(audioPath);
|
||
}
|
||
}
|
||
|
||
private static ExternalAudioStream[] CreateSingleFallbackStream(string audioPath) =>
|
||
[
|
||
new ExternalAudioStream
|
||
{
|
||
FileFullPath = audioPath,
|
||
StreamOrdinal = 0,
|
||
CodecName = GuessCodecFromExtension(Path.GetExtension(audioPath)),
|
||
TitleFromProbe = null,
|
||
Channels = null,
|
||
SampleRateHz = null,
|
||
BitRateBps = null
|
||
}
|
||
];
|
||
|
||
internal static string GuessCodecFromExtension(string? ext)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(ext))
|
||
{
|
||
return "?";
|
||
}
|
||
|
||
ext = ext.Trim().ToLowerInvariant();
|
||
return ext switch
|
||
{
|
||
".ac3" => "ac3",
|
||
".eac3" => "eac3",
|
||
".dts" => "dts",
|
||
".aac" => "aac",
|
||
".mp3" => "mp3",
|
||
".opus" => "opus",
|
||
".ogg" => "vorbis",
|
||
".flac" => "flac",
|
||
".wav" => "pcm_s16le",
|
||
".wma" => "wmav2",
|
||
".mka" or ".mkv" or ".mp4" or ".m4a" => "unknown",
|
||
_ => ext.TrimStart('.')
|
||
};
|
||
}
|
||
}
|