using System.Collections.Generic;
using System.IO;
using System.Linq;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
/// Поиск внешних аудио и субтитров рядом с видеофайлом; для контейнеров с несколькими аудиопотоками — ffprobe.
public sealed class SidecarDiscoveryService
{
private static readonly StringComparer IC = StringComparer.OrdinalIgnoreCase;
private static readonly HashSet AudioExts = new(IC)
{
".mka", ".mkv", ".mp4", ".m4a",
".ac3", ".eac3", ".aac", ".dts", ".flac", ".wav", ".opus", ".ogg", ".mp3", ".wma", ".aiff", ".aif", ".m4b", ".m4r"
};
/// Расширения: внутри файла может быть несколько аудиопотоков → полный ffprobe.
private static readonly HashSet MultiStreamAudioProbeExts = new(IC)
{
".mka", ".mkv", ".mp4", ".m4a"
};
private static readonly HashSet SubExts = new(IC)
{
".srt", ".ass", ".ssa", ".vtt", ".sub", ".idx", ".sup", ".smi"
};
private static readonly HashSet FontExts = new(IC)
{
".ttf", ".otf", ".ttc", ".otc"
};
private readonly LoggingService? _logging;
public SidecarDiscoveryService(LoggingService? logging = null) => _logging = logging;
public async Task DiscoverAsync(
string videoPath,
FfprobeService ffprobe,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(videoPath) || !File.Exists(videoPath))
{
return new SidecarDiscoveryResult(Array.Empty(), Array.Empty());
}
var dir = Path.GetDirectoryName(videoPath);
if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir))
{
return new SidecarDiscoveryResult(Array.Empty(), Array.Empty());
}
var baseName = Path.GetFileNameWithoutExtension(videoPath);
var full = Path.GetFullPath(videoPath);
var list = new List();
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 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(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> 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(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('.')
};
}
}