emby-toolbox/EmbyToolbox/Services/SidecarDiscoveryService.cs
Emby Toolbox 6264b487fe Initial commit: Emby Toolbox (conversion scroll fix, bulk Del for tracks).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 21:33:47 +05:00

222 lines
7.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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