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('.') }; } }