diff --git a/BililiveRecorder.Cli/BililiveRecorder.Cli.csproj b/BililiveRecorder.Cli/BililiveRecorder.Cli.csproj index 21d8ba6..039f964 100644 --- a/BililiveRecorder.Cli/BililiveRecorder.Cli.csproj +++ b/BililiveRecorder.Cli/BililiveRecorder.Cli.csproj @@ -28,8 +28,9 @@ - + + diff --git a/BililiveRecorder.Cli/Program.cs b/BililiveRecorder.Cli/Program.cs index 689a531..dcec629 100644 --- a/BililiveRecorder.Cli/Program.cs +++ b/BililiveRecorder.Cli/Program.cs @@ -2,11 +2,11 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading; -using Autofac; using BililiveRecorder.Core; using BililiveRecorder.Core.Config.V2; -using BililiveRecorder.FlvProcessor; +using BililiveRecorder.DependencyInjection; using CommandLine; +using Microsoft.Extensions.DependencyInjection; namespace BililiveRecorder.Cli { @@ -19,10 +19,10 @@ namespace BililiveRecorder.Cli private static int RunConfigMode(CmdVerbConfigMode opts) { - var container = CreateBuilder().Build(); - var rootScope = container.BeginLifetimeScope("recorder_root"); var semaphore = new SemaphoreSlim(0, 1); - var recorder = rootScope.Resolve(); + + var serviceProvider = BuildServiceProvider(); + var recorder = serviceProvider.GetRequiredService(); ConsoleCancelEventHandler p = null!; p = (sender, e) => @@ -46,10 +46,11 @@ namespace BililiveRecorder.Cli private static int RunPortableMode(CmdVerbPortableMode opts) { - var container = CreateBuilder().Build(); - var rootScope = container.BeginLifetimeScope("recorder_root"); var semaphore = new SemaphoreSlim(0, 1); - var recorder = rootScope.Resolve(); + + var serviceProvider = BuildServiceProvider(); + var recorder = serviceProvider.GetRequiredService(); + var config = new ConfigV2() { DisableConfigSave = true, @@ -85,12 +86,12 @@ namespace BililiveRecorder.Cli return 0; } - private static ContainerBuilder CreateBuilder() + private static IServiceProvider BuildServiceProvider() { - var builder = new ContainerBuilder(); - builder.RegisterModule(); - builder.RegisterModule(); - return builder; + var services = new ServiceCollection(); + services.AddFlvProcessor(); + services.AddCore(); + return services.BuildServiceProvider(); } } diff --git a/BililiveRecorder.Core/BililiveRecorder.Core.csproj b/BililiveRecorder.Core/BililiveRecorder.Core.csproj index 7161adb..9a65e69 100644 --- a/BililiveRecorder.Core/BililiveRecorder.Core.csproj +++ b/BililiveRecorder.Core/BililiveRecorder.Core.csproj @@ -19,10 +19,10 @@ - - + + diff --git a/BililiveRecorder.Core/CoreModule.cs b/BililiveRecorder.Core/CoreModule.cs deleted file mode 100644 index 7f10082..0000000 --- a/BililiveRecorder.Core/CoreModule.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Net.Sockets; -using Autofac; -using BililiveRecorder.Core.Config.V2; - -#nullable enable -namespace BililiveRecorder.Core -{ - public class CoreModule : Module - { - public CoreModule() - { - } - - protected override void Load(ContainerBuilder builder) - { - builder.Register(x => x.Resolve().Config).As(); - builder.Register(x => x.Resolve().Global).As(); - builder.RegisterType().AsSelf().InstancePerMatchingLifetimeScope("recorder_root"); - builder.RegisterType().AsSelf().ExternallyOwned(); - builder.RegisterType().As().ExternallyOwned(); - builder.RegisterType().As().ExternallyOwned(); - builder.RegisterType().As().ExternallyOwned(); - builder.RegisterType().As().InstancePerMatchingLifetimeScope("recorder_root"); - } - } -} diff --git a/BililiveRecorder.Core/DependencyInjectionExtensions.cs b/BililiveRecorder.Core/DependencyInjectionExtensions.cs new file mode 100644 index 0000000..e65cb1f --- /dev/null +++ b/BililiveRecorder.Core/DependencyInjectionExtensions.cs @@ -0,0 +1,20 @@ +using BililiveRecorder.Core; +using BililiveRecorder.Core.Config.V2; +using Microsoft.Extensions.DependencyInjection; + +namespace BililiveRecorder.DependencyInjection +{ + public static class DependencyInjectionExtensions + { + public static void AddCore(this IServiceCollection services) + { + services.AddSingleton(); +#pragma warning disable IDE0001 + services.AddSingleton(x => x.GetRequiredService().Config); + services.AddSingleton(x => x.GetRequiredService().Global); +#pragma warning restore IDE0001 + services.AddSingleton(); + services.AddSingleton(); + } + } +} diff --git a/BililiveRecorder.Core/IRecordedRoomFactory.cs b/BililiveRecorder.Core/IRecordedRoomFactory.cs new file mode 100644 index 0000000..ebb677d --- /dev/null +++ b/BililiveRecorder.Core/IRecordedRoomFactory.cs @@ -0,0 +1,9 @@ +using BililiveRecorder.Core.Config.V2; + +namespace BililiveRecorder.Core +{ + public interface IRecordedRoomFactory + { + IRecordedRoom CreateRecordedRoom(RoomConfig roomConfig); + } +} diff --git a/BililiveRecorder.Core/RecordedRoom.cs b/BililiveRecorder.Core/RecordedRoom.cs index 600f077..8f2f603 100644 --- a/BililiveRecorder.Core/RecordedRoom.cs +++ b/BililiveRecorder.Core/RecordedRoom.cs @@ -113,7 +113,7 @@ namespace BililiveRecorder.Core public event EventHandler RecordEnded; private readonly IBasicDanmakuWriter basicDanmakuWriter; - private readonly Func newIFlvStreamProcessor; + private readonly IProcessorFactory processorFactory; private IFlvStreamProcessor _processor; public IFlvStreamProcessor Processor { @@ -156,9 +156,9 @@ namespace BililiveRecorder.Core public Guid Guid { get; } = Guid.NewGuid(); // TODO: 重构 DI - public RecordedRoom(Func newBasicDanmakuWriter, - Func newIStreamMonitor, - Func newIFlvStreamProcessor, + public RecordedRoom(IBasicDanmakuWriter basicDanmakuWriter, + IStreamMonitor streamMonitor, + IProcessorFactory processorFactory, BililiveAPI bililiveAPI, RoomConfig roomConfig) { @@ -167,11 +167,11 @@ namespace BililiveRecorder.Core this.BililiveAPI = bililiveAPI; - this.newIFlvStreamProcessor = newIFlvStreamProcessor; + this.processorFactory = processorFactory; - this.basicDanmakuWriter = newBasicDanmakuWriter(this.RoomConfig); + this.basicDanmakuWriter = basicDanmakuWriter; - this.StreamMonitor = newIStreamMonitor(this.RoomConfig); + this.StreamMonitor = streamMonitor; this.StreamMonitor.RoomInfoUpdated += this.StreamMonitor_RoomInfoUpdated; this.StreamMonitor.StreamStarted += this.StreamMonitor_StreamStarted; this.StreamMonitor.ReceivedDanmaku += this.StreamMonitor_ReceivedDanmaku; @@ -377,7 +377,7 @@ namespace BililiveRecorder.Core } else { - this.Processor = this.newIFlvStreamProcessor().Initialize(this.GetStreamFilePath, this.GetClipFilePath, this.RoomConfig.EnabledFeature, this.RoomConfig.CuttingMode); + this.Processor = this.processorFactory.CreateStreamProcessor().Initialize(this.GetStreamFilePath, this.GetClipFilePath, this.RoomConfig.EnabledFeature, this.RoomConfig.CuttingMode); this.Processor.ClipLengthFuture = this.RoomConfig.ClipLengthFuture; this.Processor.ClipLengthPast = this.RoomConfig.ClipLengthPast; this.Processor.CuttingNumber = this.RoomConfig.CuttingNumber; diff --git a/BililiveRecorder.Core/RecordedRoomFactory.cs b/BililiveRecorder.Core/RecordedRoomFactory.cs new file mode 100644 index 0000000..547f72a --- /dev/null +++ b/BililiveRecorder.Core/RecordedRoomFactory.cs @@ -0,0 +1,25 @@ +using System; +using BililiveRecorder.Core.Config.V2; +using BililiveRecorder.FlvProcessor; + +namespace BililiveRecorder.Core +{ + public class RecordedRoomFactory : IRecordedRoomFactory + { + private readonly IProcessorFactory processorFactory; + private readonly BililiveAPI bililiveAPI; + + public RecordedRoomFactory(IProcessorFactory processorFactory, BililiveAPI bililiveAPI) + { + this.processorFactory = processorFactory ?? throw new ArgumentNullException(nameof(processorFactory)); + this.bililiveAPI = bililiveAPI ?? throw new ArgumentNullException(nameof(bililiveAPI)); + } + + public IRecordedRoom CreateRecordedRoom(RoomConfig roomConfig) + { + var basicDanmakuWriter = new BasicDanmakuWriter(roomConfig); + var streamMonitor = new StreamMonitor(roomConfig, this.bililiveAPI); + return new RecordedRoom(basicDanmakuWriter, streamMonitor, this.processorFactory, this.bililiveAPI, roomConfig); + } + } +} diff --git a/BililiveRecorder.Core/Recorder.cs b/BililiveRecorder.Core/Recorder.cs index 6d33f2e..8bc1a5a 100644 --- a/BililiveRecorder.Core/Recorder.cs +++ b/BililiveRecorder.Core/Recorder.cs @@ -9,6 +9,7 @@ using System.Threading; using BililiveRecorder.Core.Callback; using BililiveRecorder.Core.Config; using BililiveRecorder.Core.Config.V2; +using Microsoft.Extensions.DependencyInjection; using NLog; #nullable enable @@ -18,9 +19,9 @@ namespace BililiveRecorder.Core { private static readonly Logger logger = LogManager.GetCurrentClassLogger(); - private readonly Func newIRecordedRoom; private readonly CancellationTokenSource tokenSource; - + private readonly IServiceProvider serviceProvider; + private IRecordedRoomFactory? recordedRoomFactory; private bool _valid = false; private bool disposedValue; @@ -34,10 +35,9 @@ namespace BililiveRecorder.Core public bool IsReadOnly => true; public IRecordedRoom this[int index] => this.Rooms[index]; - public Recorder(Func iRecordedRoom) + public Recorder(IServiceProvider serviceProvider) { - this.newIRecordedRoom = iRecordedRoom ?? throw new ArgumentNullException(nameof(iRecordedRoom)); - + this.serviceProvider = serviceProvider; this.tokenSource = new CancellationTokenSource(); Repeat.Interval(TimeSpan.FromSeconds(3), this.DownloadWatchdog, this.tokenSource.Token); @@ -60,6 +60,7 @@ namespace BililiveRecorder.Core this.Config = config; this.Config.Global.WorkDirectory = workdir; this.Webhook = new BasicWebhook(this.Config); + this.recordedRoomFactory = this.serviceProvider.GetRequiredService(); this._valid = true; this.Config.Rooms.ForEach(r => this.AddRoom(r)); ConfigParser.SaveTo(this.Config.Global.WorkDirectory, this.Config); @@ -83,6 +84,7 @@ namespace BililiveRecorder.Core logger.Debug("Initialize With Config."); this.Config = config; this.Webhook = new BasicWebhook(this.Config); + this.recordedRoomFactory = this.serviceProvider.GetRequiredService(); this._valid = true; this.Config.Rooms.ForEach(r => this.AddRoom(r)); return true; @@ -136,7 +138,7 @@ namespace BililiveRecorder.Core if (!this._valid) { throw new InvalidOperationException("Not Initialized"); } roomConfig.SetParent(this.Config?.Global); - var rr = this.newIRecordedRoom(roomConfig); + var rr = this.recordedRoomFactory!.CreateRecordedRoom(roomConfig); logger.Debug("AddRoom 添加了 {roomid} 直播间 ", rr.RoomId); rr.RecordEnded += this.RecordedRoom_RecordEnded; diff --git a/BililiveRecorder.Core/StreamMonitor.cs b/BililiveRecorder.Core/StreamMonitor.cs index ba0d5c2..4950128 100644 --- a/BililiveRecorder.Core/StreamMonitor.cs +++ b/BililiveRecorder.Core/StreamMonitor.cs @@ -30,7 +30,6 @@ namespace BililiveRecorder.Core { private static readonly Logger logger = LogManager.GetCurrentClassLogger(); - private readonly Func funcTcpClient; private readonly RoomConfig roomConfig; private readonly BililiveAPI bililiveAPI; @@ -51,9 +50,8 @@ namespace BililiveRecorder.Core public event ReceivedDanmakuEvt ReceivedDanmaku; public event PropertyChangedEventHandler PropertyChanged; - public StreamMonitor(RoomConfig roomConfig, Func funcTcpClient, BililiveAPI bililiveAPI) + public StreamMonitor(RoomConfig roomConfig, BililiveAPI bililiveAPI) { - this.funcTcpClient = funcTcpClient; this.roomConfig = roomConfig; this.bililiveAPI = bililiveAPI; @@ -216,7 +214,7 @@ namespace BililiveRecorder.Core logger.Log(this.RoomId, LogLevel.Debug, $"连接弹幕服务器 {host}:{port} {(string.IsNullOrWhiteSpace(token) ? "无" : "有")} token"); - this.dmClient = this.funcTcpClient(); + this.dmClient = new TcpClient(); await this.dmClient.ConnectAsync(host, port).ConfigureAwait(false); this.dmNetStream = this.dmClient.GetStream(); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.IsDanmakuConnected))); diff --git a/BililiveRecorder.Flv/Amf/AmfCollectionDebugView.cs b/BililiveRecorder.Flv/Amf/AmfCollectionDebugView.cs new file mode 100644 index 0000000..450b338 --- /dev/null +++ b/BililiveRecorder.Flv/Amf/AmfCollectionDebugView.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace BililiveRecorder.Flv.Amf +{ + internal sealed class AmfCollectionDebugView + { + private readonly ICollection _collection; + + public AmfCollectionDebugView(ICollection collection) + { + this._collection = collection ?? throw new ArgumentNullException(nameof(collection)); + } + + [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] + public IScriptDataValue[] Items => this._collection.ToArray(); + } +} diff --git a/BililiveRecorder.Flv/Amf/AmfDictionaryDebugView.cs b/BililiveRecorder.Flv/Amf/AmfDictionaryDebugView.cs new file mode 100644 index 0000000..4157078 --- /dev/null +++ b/BililiveRecorder.Flv/Amf/AmfDictionaryDebugView.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace BililiveRecorder.Flv.Amf +{ + internal sealed class AmfDictionaryDebugView + { + private readonly IDictionary _dict; + + public AmfDictionaryDebugView(IDictionary dictionary) + { + this._dict = dictionary ?? throw new ArgumentNullException(nameof(dictionary)); + } + + [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] + public KeyValuePairDebugView[] Items + => this._dict.Select(x => new KeyValuePairDebugView(x.Key, x.Value)).ToArray(); + } +} diff --git a/BililiveRecorder.Flv/Amf/AmfException.cs b/BililiveRecorder.Flv/Amf/AmfException.cs new file mode 100644 index 0000000..acab0b7 --- /dev/null +++ b/BililiveRecorder.Flv/Amf/AmfException.cs @@ -0,0 +1,17 @@ +using System; +using System.Runtime.Serialization; + +namespace BililiveRecorder.Flv.Amf +{ + public class AmfException : Exception + { + /// + public AmfException() { } + /// + public AmfException(string message) : base(message) { } + /// + public AmfException(string message, Exception innerException) : base(message, innerException) { } + /// + protected AmfException(SerializationInfo info, StreamingContext context) : base(info, context) { } + } +} diff --git a/BililiveRecorder.Flv/Amf/IScriptDataValue.cs b/BililiveRecorder.Flv/Amf/IScriptDataValue.cs new file mode 100644 index 0000000..d857355 --- /dev/null +++ b/BililiveRecorder.Flv/Amf/IScriptDataValue.cs @@ -0,0 +1,26 @@ +using System.IO; +using JsonSubTypes; +using Newtonsoft.Json; + +namespace BililiveRecorder.Flv.Amf +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + [JsonConverter(typeof(JsonSubtypes), nameof(Type))] + [JsonSubtypes.KnownSubType(typeof(ScriptDataNumber), ScriptDataType.Number)] + [JsonSubtypes.KnownSubType(typeof(ScriptDataBoolean), ScriptDataType.Boolean)] + [JsonSubtypes.KnownSubType(typeof(ScriptDataString), ScriptDataType.String)] + [JsonSubtypes.KnownSubType(typeof(ScriptDataObject), ScriptDataType.Object)] + [JsonSubtypes.KnownSubType(typeof(ScriptDataNull), ScriptDataType.Null)] + [JsonSubtypes.KnownSubType(typeof(ScriptDataUndefined), ScriptDataType.Undefined)] + [JsonSubtypes.KnownSubType(typeof(ScriptDataReference), ScriptDataType.Reference)] + [JsonSubtypes.KnownSubType(typeof(ScriptDataEcmaArray), ScriptDataType.EcmaArray)] + [JsonSubtypes.KnownSubType(typeof(ScriptDataStrictArray), ScriptDataType.StrictArray)] + [JsonSubtypes.KnownSubType(typeof(ScriptDataDate), ScriptDataType.Date)] + [JsonSubtypes.KnownSubType(typeof(ScriptDataLongString), ScriptDataType.LongString)] + public interface IScriptDataValue + { + [JsonProperty] + ScriptDataType Type { get; } + void WriteTo(Stream stream); + } +} diff --git a/BililiveRecorder.Flv/Amf/KeyValuePairDebugView.cs b/BililiveRecorder.Flv/Amf/KeyValuePairDebugView.cs new file mode 100644 index 0000000..386a231 --- /dev/null +++ b/BililiveRecorder.Flv/Amf/KeyValuePairDebugView.cs @@ -0,0 +1,17 @@ +using System.Diagnostics; + +namespace BililiveRecorder.Flv.Amf +{ + [DebuggerDisplay("{Key}: {Value}")] + internal sealed class KeyValuePairDebugView + { + public KeyValuePairDebugView(K key, V value) + { + this.Key = key; + this.Value = value; + } + + public K Key { get; } + public V Value { get; } + } +} diff --git a/BililiveRecorder.Flv/Amf/ScriptDataBoolean.cs b/BililiveRecorder.Flv/Amf/ScriptDataBoolean.cs new file mode 100644 index 0000000..0e5b810 --- /dev/null +++ b/BililiveRecorder.Flv/Amf/ScriptDataBoolean.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using Newtonsoft.Json; + +namespace BililiveRecorder.Flv.Amf +{ + [DebuggerDisplay("AmfBoolean, {Value}")] + public class ScriptDataBoolean : IScriptDataValue + { + public ScriptDataType Type => ScriptDataType.Boolean; + + [JsonProperty] + public bool Value { get; set; } + + public void WriteTo(Stream stream) + { + stream.WriteByte((byte)this.Type); + stream.WriteByte((byte)(this.Value ? 1 : 0)); + } + + public override bool Equals(object? obj) => obj is ScriptDataBoolean boolean && this.Value == boolean.Value; + public override int GetHashCode() => HashCode.Combine(this.Value); + public static bool operator ==(ScriptDataBoolean left, ScriptDataBoolean right) => EqualityComparer.Default.Equals(left, right); + public static bool operator !=(ScriptDataBoolean left, ScriptDataBoolean right) => !(left == right); + public static implicit operator bool(ScriptDataBoolean boolean) => boolean.Value; + public static implicit operator ScriptDataBoolean(bool boolean) => new ScriptDataBoolean { Value = boolean }; + } +} diff --git a/BililiveRecorder.Flv/Amf/ScriptDataDate.cs b/BililiveRecorder.Flv/Amf/ScriptDataDate.cs new file mode 100644 index 0000000..bda7efc --- /dev/null +++ b/BililiveRecorder.Flv/Amf/ScriptDataDate.cs @@ -0,0 +1,51 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using Newtonsoft.Json; + +namespace BililiveRecorder.Flv.Amf +{ + [DebuggerDisplay("AmfDate, {Value}")] + public class ScriptDataDate : IScriptDataValue + { + public ScriptDataDate() { } + public ScriptDataDate(DateTimeOffset value) + { + this.Value = value; + } + + public ScriptDataDate(double dateTime, short localDateTimeOffset) + { + this.Value = DateTimeOffset.FromUnixTimeMilliseconds((long)dateTime).ToOffset(TimeSpan.FromMinutes(localDateTimeOffset)); + } + + public ScriptDataType Type => ScriptDataType.Date; + + [JsonProperty] + public DateTimeOffset Value { get; set; } + + public void WriteTo(Stream stream) + { + var dateTime = (double)this.Value.ToUnixTimeMilliseconds(); + var localDateTimeOffset = (short)this.Value.Offset.TotalMinutes; + var buffer1 = new byte[sizeof(double)]; + var buffer2 = new byte[sizeof(ushort)]; + BinaryPrimitives.WriteInt64BigEndian(buffer1, BitConverter.DoubleToInt64Bits(dateTime)); + BinaryPrimitives.WriteInt16BigEndian(buffer2, localDateTimeOffset); + stream.WriteByte((byte)this.Type); + stream.Write(buffer1); + stream.Write(buffer2); + } + + public override bool Equals(object? obj) => obj is ScriptDataDate date && this.Value.Equals(date.Value); + public override int GetHashCode() => HashCode.Combine(this.Value); + public static bool operator ==(ScriptDataDate left, ScriptDataDate right) => EqualityComparer.Default.Equals(left, right); + public static bool operator !=(ScriptDataDate left, ScriptDataDate right) => !(left == right); + public static implicit operator DateTimeOffset(ScriptDataDate date) => date.Value; + public static implicit operator ScriptDataDate(DateTimeOffset date) => new ScriptDataDate(date); + public static implicit operator DateTime(ScriptDataDate date) => date.Value.DateTime; + public static implicit operator ScriptDataDate(DateTime date) => new ScriptDataDate(date); + } +} diff --git a/BililiveRecorder.Flv/Amf/ScriptDataEcmaArray.cs b/BililiveRecorder.Flv/Amf/ScriptDataEcmaArray.cs new file mode 100644 index 0000000..13bc14b --- /dev/null +++ b/BililiveRecorder.Flv/Amf/ScriptDataEcmaArray.cs @@ -0,0 +1,75 @@ +using System.Buffers.Binary; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Text; +using Newtonsoft.Json; + +namespace BililiveRecorder.Flv.Amf +{ + [DebuggerTypeProxy(typeof(AmfDictionaryDebugView))] + [DebuggerDisplay("AmfEcmaArray, Count = {Count}")] + public class ScriptDataEcmaArray : IScriptDataValue, IDictionary, ICollection>, IEnumerable>, IReadOnlyCollection>, IReadOnlyDictionary + { + public ScriptDataType Type => ScriptDataType.EcmaArray; + + [JsonProperty] + public Dictionary Value { get; set; } = new Dictionary(); + + public void WriteTo(Stream stream) + { + stream.WriteByte((byte)this.Type); + + { + var buffer = new byte[sizeof(uint)]; + BinaryPrimitives.WriteUInt32BigEndian(buffer, (uint)this.Value.Count); + stream.Write(buffer); + } + + foreach (var item in this.Value) + { + // key + var bytes = Encoding.UTF8.GetBytes(item.Key); + if (bytes.Length > ushort.MaxValue) + throw new AmfException($"Cannot write more than {ushort.MaxValue} into ScriptDataString"); + + var buffer = new byte[sizeof(ushort)]; + BinaryPrimitives.WriteUInt16BigEndian(buffer, (ushort)bytes.Length); + + stream.Write(buffer); + stream.Write(bytes); + + // value + item.Value.WriteTo(stream); + } + + stream.Write(new byte[] { 0, 0, 9 }); + } + + public IScriptDataValue this[string key] { get => ((IDictionary)this.Value)[key]; set => ((IDictionary)this.Value)[key] = value; } + public ICollection Keys => ((IDictionary)this.Value).Keys; + public ICollection Values => ((IDictionary)this.Value).Values; + IEnumerable IReadOnlyDictionary.Keys => ((IReadOnlyDictionary)this.Value).Keys; + IEnumerable IReadOnlyDictionary.Values => ((IReadOnlyDictionary)this.Value).Values; + public int Count => ((IDictionary)this.Value).Count; + public bool IsReadOnly => ((IDictionary)this.Value).IsReadOnly; + public void Add(string key, IScriptDataValue value) => ((IDictionary)this.Value).Add(key, value); + public void Add(KeyValuePair item) => ((IDictionary)this.Value).Add(item); + public void Clear() => ((IDictionary)this.Value).Clear(); + public bool Contains(KeyValuePair item) => ((IDictionary)this.Value).Contains(item); + public bool ContainsKey(string key) => ((IDictionary)this.Value).ContainsKey(key); + public void CopyTo(KeyValuePair[] array, int arrayIndex) => ((IDictionary)this.Value).CopyTo(array, arrayIndex); + public IEnumerator> GetEnumerator() => ((IDictionary)this.Value).GetEnumerator(); + public bool Remove(string key) => ((IDictionary)this.Value).Remove(key); + public bool Remove(KeyValuePair item) => ((IDictionary)this.Value).Remove(item); +#pragma warning disable CS8767 // Nullability of reference types in type of parameter doesn't match implicitly implemented member (possibly because of nullability attributes). + public bool TryGetValue(string key, [MaybeNullWhen(false)] out IScriptDataValue value) => ((IDictionary)this.Value).TryGetValue(key, out value!); +#pragma warning restore CS8767 // Nullability of reference types in type of parameter doesn't match implicitly implemented member (possibly because of nullability attributes). + IEnumerator IEnumerable.GetEnumerator() => ((IDictionary)this.Value).GetEnumerator(); + public static implicit operator Dictionary(ScriptDataEcmaArray ecmaArray) => ecmaArray.Value; + public static implicit operator ScriptDataEcmaArray(Dictionary ecmaArray) => new ScriptDataEcmaArray { Value = ecmaArray }; + public static implicit operator ScriptDataEcmaArray(ScriptDataObject @object) => new ScriptDataEcmaArray { Value = @object }; + } +} diff --git a/BililiveRecorder.Flv/Amf/ScriptDataLongString.cs b/BililiveRecorder.Flv/Amf/ScriptDataLongString.cs new file mode 100644 index 0000000..a40e529 --- /dev/null +++ b/BililiveRecorder.Flv/Amf/ScriptDataLongString.cs @@ -0,0 +1,39 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using Newtonsoft.Json; + +namespace BililiveRecorder.Flv.Amf +{ + [DebuggerDisplay("AmfLongString, {Value}")] + public class ScriptDataLongString : IScriptDataValue + { + public ScriptDataType Type => ScriptDataType.LongString; + + [JsonProperty(Required = Required.Always)] + public string Value { get; set; } = string.Empty; + + public void WriteTo(Stream stream) + { + var bytes = Encoding.UTF8.GetBytes(this.Value); + + var buffer = new byte[sizeof(uint)]; + BinaryPrimitives.WriteUInt32BigEndian(buffer, (uint)bytes.Length); + + stream.WriteByte((byte)this.Type); + stream.Write(buffer); + stream.Write(bytes); + } + + public override bool Equals(object? obj) => obj is ScriptDataLongString @string && this.Value == @string.Value; + public override int GetHashCode() => HashCode.Combine(this.Value); + public static bool operator ==(ScriptDataLongString left, ScriptDataLongString right) => EqualityComparer.Default.Equals(left, right); + public static bool operator !=(ScriptDataLongString left, ScriptDataLongString right) => !(left == right); + public static implicit operator string(ScriptDataLongString @string) => @string.Value; + public static implicit operator ScriptDataLongString(string @string) => new ScriptDataLongString { Value = @string }; + public static implicit operator ScriptDataLongString(ScriptDataString @string) => new ScriptDataLongString { Value = @string.Value }; + } +} diff --git a/BililiveRecorder.Flv/Amf/ScriptDataNull.cs b/BililiveRecorder.Flv/Amf/ScriptDataNull.cs new file mode 100644 index 0000000..58f27aa --- /dev/null +++ b/BililiveRecorder.Flv/Amf/ScriptDataNull.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; + +namespace BililiveRecorder.Flv.Amf +{ + [DebuggerDisplay("AmfNull")] + public class ScriptDataNull : IScriptDataValue + { + public ScriptDataType Type => ScriptDataType.Null; + + public void WriteTo(Stream stream) => stream.WriteByte((byte)this.Type); + + public override bool Equals(object? obj) => obj is ScriptDataNull; + public override int GetHashCode() => 0; + public static bool operator ==(ScriptDataNull left, ScriptDataNull right) => EqualityComparer.Default.Equals(left, right); + public static bool operator !=(ScriptDataNull left, ScriptDataNull right) => !(left == right); + } +} diff --git a/BililiveRecorder.Flv/Amf/ScriptDataNumber.cs b/BililiveRecorder.Flv/Amf/ScriptDataNumber.cs new file mode 100644 index 0000000..73f0d83 --- /dev/null +++ b/BililiveRecorder.Flv/Amf/ScriptDataNumber.cs @@ -0,0 +1,35 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using Newtonsoft.Json; + +namespace BililiveRecorder.Flv.Amf +{ + [DebuggerDisplay("AmfNumber, {Value}")] + public class ScriptDataNumber : IScriptDataValue + { + public ScriptDataType Type => ScriptDataType.Number; + + [JsonProperty] + public double Value { get; set; } + + public void WriteTo(Stream stream) + { + stream.WriteByte((byte)this.Type); + var buffer = new byte[sizeof(double)]; + BinaryPrimitives.WriteInt64BigEndian(buffer, BitConverter.DoubleToInt64Bits(this.Value)); + stream.Write(buffer); + } + + public static bool operator ==(ScriptDataNumber left, ScriptDataNumber right) => EqualityComparer.Default.Equals(left, right); + public static bool operator !=(ScriptDataNumber left, ScriptDataNumber right) => !(left == right); + public override bool Equals(object? obj) => obj is ScriptDataNumber number && this.Value == number.Value; + public override int GetHashCode() => HashCode.Combine(this.Value); + public static implicit operator double(ScriptDataNumber number) => number.Value; + public static explicit operator int(ScriptDataNumber number) => (int)number.Value; + public static implicit operator ScriptDataNumber(double number) => new ScriptDataNumber { Value = number }; + public static explicit operator ScriptDataNumber(int number) => new ScriptDataNumber { Value = number }; + } +} diff --git a/BililiveRecorder.Flv/Amf/ScriptDataObject.cs b/BililiveRecorder.Flv/Amf/ScriptDataObject.cs new file mode 100644 index 0000000..4edd671 --- /dev/null +++ b/BililiveRecorder.Flv/Amf/ScriptDataObject.cs @@ -0,0 +1,70 @@ +using System.Buffers.Binary; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Text; +using Newtonsoft.Json; + +namespace BililiveRecorder.Flv.Amf +{ + [DebuggerTypeProxy(typeof(AmfDictionaryDebugView))] + [DebuggerDisplay("AmfObject, Count = {Count}")] + public class ScriptDataObject : IScriptDataValue, IDictionary, ICollection>, IEnumerable>, IReadOnlyCollection>, IReadOnlyDictionary + { + public ScriptDataType Type => ScriptDataType.Object; + + [JsonProperty] + public Dictionary Value { get; set; } = new Dictionary(); + + public void WriteTo(Stream stream) + { + stream.WriteByte((byte)this.Type); + + foreach (var item in this.Value) + { + // key + var bytes = Encoding.UTF8.GetBytes(item.Key); + if (bytes.Length > ushort.MaxValue) + throw new AmfException($"Cannot write more than {ushort.MaxValue} into ScriptDataString"); + + var buffer = new byte[sizeof(ushort)]; + BinaryPrimitives.WriteUInt16BigEndian(buffer, (ushort)bytes.Length); + + stream.Write(buffer); + stream.Write(bytes); + + // value + item.Value.WriteTo(stream); + } + + stream.Write(new byte[] { 0, 0, 9 }); + } + + public IScriptDataValue this[string key] { get => ((IDictionary)this.Value)[key]; set => ((IDictionary)this.Value)[key] = value; } + public ICollection Keys => ((IDictionary)this.Value).Keys; + public ICollection Values => ((IDictionary)this.Value).Values; + IEnumerable IReadOnlyDictionary.Keys => ((IReadOnlyDictionary)this.Value).Keys; + IEnumerable IReadOnlyDictionary.Values => ((IReadOnlyDictionary)this.Value).Values; + public int Count => ((IDictionary)this.Value).Count; + public bool IsReadOnly => ((IDictionary)this.Value).IsReadOnly; + public void Add(string key, IScriptDataValue value) => ((IDictionary)this.Value).Add(key, value); + public void Add(KeyValuePair item) => ((IDictionary)this.Value).Add(item); + public void Clear() => ((IDictionary)this.Value).Clear(); + public bool Contains(KeyValuePair item) => ((IDictionary)this.Value).Contains(item); + public bool ContainsKey(string key) => ((IDictionary)this.Value).ContainsKey(key); + public void CopyTo(KeyValuePair[] array, int arrayIndex) => ((IDictionary)this.Value).CopyTo(array, arrayIndex); + public IEnumerator> GetEnumerator() => ((IDictionary)this.Value).GetEnumerator(); + public bool Remove(string key) => ((IDictionary)this.Value).Remove(key); + public bool Remove(KeyValuePair item) => ((IDictionary)this.Value).Remove(item); +#pragma warning disable CS8767 // Nullability of reference types in type of parameter doesn't match implicitly implemented member (possibly because of nullability attributes). + public bool TryGetValue(string key, [MaybeNullWhen(false)] out IScriptDataValue value) => ((IDictionary)this.Value).TryGetValue(key, out value!); +#pragma warning restore CS8767 // Nullability of reference types in type of parameter doesn't match implicitly implemented member (possibly because of nullability attributes). + IEnumerator IEnumerable.GetEnumerator() => ((IDictionary)this.Value).GetEnumerator(); + + public static implicit operator Dictionary(ScriptDataObject @object) => @object.Value; + public static implicit operator ScriptDataObject(Dictionary @object) => new ScriptDataObject { Value = @object }; + public static implicit operator ScriptDataObject(ScriptDataEcmaArray ecmaArray) => new ScriptDataObject { Value = ecmaArray }; + } +} diff --git a/BililiveRecorder.Flv/Amf/ScriptDataReference.cs b/BililiveRecorder.Flv/Amf/ScriptDataReference.cs new file mode 100644 index 0000000..c64cc6a --- /dev/null +++ b/BililiveRecorder.Flv/Amf/ScriptDataReference.cs @@ -0,0 +1,34 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using Newtonsoft.Json; + +namespace BililiveRecorder.Flv.Amf +{ + [DebuggerDisplay("AmfReference, {Value}")] + public class ScriptDataReference : IScriptDataValue + { + public ScriptDataType Type => ScriptDataType.Reference; + + [JsonProperty] + public ushort Value { get; set; } + + public void WriteTo(Stream stream) + { + stream.WriteByte((byte)this.Type); + + var buffer = new byte[sizeof(ushort)]; + BinaryPrimitives.WriteUInt16BigEndian(buffer, this.Value); + stream.Write(buffer); + } + + public override bool Equals(object? obj) => obj is ScriptDataReference reference && this.Value == reference.Value; + public override int GetHashCode() => HashCode.Combine(this.Value); + public static bool operator ==(ScriptDataReference left, ScriptDataReference right) => EqualityComparer.Default.Equals(left, right); + public static bool operator !=(ScriptDataReference left, ScriptDataReference right) => !(left == right); + public static implicit operator ushort(ScriptDataReference reference) => reference.Value; + public static implicit operator ScriptDataReference(ushort number) => new ScriptDataReference { Value = number }; + } +} diff --git a/BililiveRecorder.Flv/Amf/ScriptDataStrictArray.cs b/BililiveRecorder.Flv/Amf/ScriptDataStrictArray.cs new file mode 100644 index 0000000..eeea380 --- /dev/null +++ b/BililiveRecorder.Flv/Amf/ScriptDataStrictArray.cs @@ -0,0 +1,47 @@ +using System.Buffers.Binary; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using Newtonsoft.Json; + +namespace BililiveRecorder.Flv.Amf +{ + [DebuggerTypeProxy(typeof(AmfCollectionDebugView))] + [DebuggerDisplay("AmfStrictArray, Count = {Count}")] + public class ScriptDataStrictArray : IScriptDataValue, IList, ICollection, IEnumerable, IReadOnlyCollection, IReadOnlyList + { + public ScriptDataType Type => ScriptDataType.StrictArray; + + [JsonProperty] + public List Value { get; set; } = new List(); + + public void WriteTo(Stream stream) + { + stream.WriteByte((byte)this.Type); + + var buffer = new byte[sizeof(uint)]; + BinaryPrimitives.WriteUInt32BigEndian(buffer, (uint)this.Value.Count); + stream.Write(buffer); + + foreach (var item in this.Value) + item.WriteTo(stream); + } + + public IScriptDataValue this[int index] { get => ((IList)this.Value)[index]; set => ((IList)this.Value)[index] = value; } + public int Count => ((IList)this.Value).Count; + public bool IsReadOnly => ((IList)this.Value).IsReadOnly; + public void Add(IScriptDataValue item) => ((IList)this.Value).Add(item); + public void Clear() => ((IList)this.Value).Clear(); + public bool Contains(IScriptDataValue item) => ((IList)this.Value).Contains(item); + public void CopyTo(IScriptDataValue[] array, int arrayIndex) => ((IList)this.Value).CopyTo(array, arrayIndex); + public IEnumerator GetEnumerator() => ((IList)this.Value).GetEnumerator(); + public int IndexOf(IScriptDataValue item) => ((IList)this.Value).IndexOf(item); + public void Insert(int index, IScriptDataValue item) => ((IList)this.Value).Insert(index, item); + public bool Remove(IScriptDataValue item) => ((IList)this.Value).Remove(item); + public void RemoveAt(int index) => ((IList)this.Value).RemoveAt(index); + IEnumerator IEnumerable.GetEnumerator() => ((IList)this.Value).GetEnumerator(); + public static implicit operator List(ScriptDataStrictArray strictArray) => strictArray.Value; + public static implicit operator ScriptDataStrictArray(List values) => new ScriptDataStrictArray { Value = values }; + } +} diff --git a/BililiveRecorder.Flv/Amf/ScriptDataString.cs b/BililiveRecorder.Flv/Amf/ScriptDataString.cs new file mode 100644 index 0000000..e40bd3f --- /dev/null +++ b/BililiveRecorder.Flv/Amf/ScriptDataString.cs @@ -0,0 +1,41 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using Newtonsoft.Json; + +namespace BililiveRecorder.Flv.Amf +{ + [DebuggerDisplay("AmfString, {Value}")] + public class ScriptDataString : IScriptDataValue + { + public ScriptDataType Type => ScriptDataType.String; + + [JsonProperty(Required = Required.Always)] + public string Value { get; set; } = string.Empty; + + public void WriteTo(Stream stream) + { + var bytes = Encoding.UTF8.GetBytes(this.Value); + if (bytes.Length > ushort.MaxValue) + throw new AmfException($"Cannot write more than {ushort.MaxValue} into ScriptDataString"); + + var buffer = new byte[sizeof(ushort)]; + BinaryPrimitives.WriteUInt16BigEndian(buffer, (ushort)bytes.Length); + + stream.WriteByte((byte)this.Type); + stream.Write(buffer); + stream.Write(bytes); + } + + public override bool Equals(object? obj) => obj is ScriptDataString @string && this.Value == @string.Value; + public override int GetHashCode() => HashCode.Combine(this.Value); + public static bool operator ==(ScriptDataString left, ScriptDataString right) => EqualityComparer.Default.Equals(left, right); + public static bool operator !=(ScriptDataString left, ScriptDataString right) => !(left == right); + public static implicit operator string(ScriptDataString @string) => @string.Value; + public static implicit operator ScriptDataString(string @string) => new ScriptDataString { Value = @string }; + public static implicit operator ScriptDataString(ScriptDataLongString @string) => new ScriptDataString { Value = @string.Value }; + } +} diff --git a/BililiveRecorder.Flv/Amf/ScriptDataType.cs b/BililiveRecorder.Flv/Amf/ScriptDataType.cs new file mode 100644 index 0000000..1d235cb --- /dev/null +++ b/BililiveRecorder.Flv/Amf/ScriptDataType.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace BililiveRecorder.Flv.Amf +{ + [JsonConverter(typeof(StringEnumConverter))] + public enum ScriptDataType : byte + { + Number = 0, + Boolean = 1, + String = 2, + Object = 3, + MovieClip = 4, + Null = 5, + Undefined = 6, + Reference = 7, + EcmaArray = 8, + ObjectEndMarker = 9, + StrictArray = 10, + Date = 11, + LongString = 12, + } +} diff --git a/BililiveRecorder.Flv/Amf/ScriptDataUndefined.cs b/BililiveRecorder.Flv/Amf/ScriptDataUndefined.cs new file mode 100644 index 0000000..41fee61 --- /dev/null +++ b/BililiveRecorder.Flv/Amf/ScriptDataUndefined.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; + +namespace BililiveRecorder.Flv.Amf +{ + [DebuggerDisplay("AmfUndefined")] + public class ScriptDataUndefined : IScriptDataValue + { + public ScriptDataType Type => ScriptDataType.Undefined; + + public void WriteTo(Stream stream) => stream.WriteByte((byte)this.Type); + + public override bool Equals(object? obj) => obj is ScriptDataUndefined; + public override int GetHashCode() => 0; + public static bool operator ==(ScriptDataUndefined left, ScriptDataUndefined right) => EqualityComparer.Default.Equals(left, right); + public static bool operator !=(ScriptDataUndefined left, ScriptDataUndefined right) => !(left == right); + } +} diff --git a/BililiveRecorder.Flv/Amf/ScriptTagBody.cs b/BililiveRecorder.Flv/Amf/ScriptTagBody.cs new file mode 100644 index 0000000..ec23d95 --- /dev/null +++ b/BililiveRecorder.Flv/Amf/ScriptTagBody.cs @@ -0,0 +1,177 @@ +using System.IO; +using System.Text; +using System.Xml; +using System.Xml.Schema; +using System.Xml.Serialization; +using BililiveRecorder.Flv.Parser; +using Newtonsoft.Json; + +namespace BililiveRecorder.Flv.Amf +{ + public class ScriptTagBody : IXmlSerializable + { + private static readonly JsonSerializerSettings jsonSerializerSettings = new JsonSerializerSettings + { + DateParseHandling = DateParseHandling.DateTimeOffset, + DateFormatHandling = DateFormatHandling.IsoDateFormat, + DateTimeZoneHandling = DateTimeZoneHandling.RoundtripKind, + }; + + public ScriptDataString Name { get; set; } = string.Empty; + + public ScriptDataObject Value { get; set; } = new ScriptDataObject(); + + public static ScriptTagBody Parse(string json) => JsonConvert.DeserializeObject(json, jsonSerializerSettings)!; + + public string ToJson() => JsonConvert.SerializeObject(this, jsonSerializerSettings); + + public static ScriptTagBody Parse(byte[] bytes) + { + using var ms = new MemoryStream(bytes); + return Parse(ms); + } + + public static ScriptTagBody Parse(Stream stream) + { + return Parse(new BigEndianBinaryReader(stream, Encoding.UTF8, true)); + } + + public static ScriptTagBody Parse(BigEndianBinaryReader binaryReader) + { + if (ParseValue(binaryReader) is ScriptDataString stringName) + return new ScriptTagBody + { + Name = stringName, + Value = ((ParseValue(binaryReader)) switch + { + ScriptDataEcmaArray value => value, + ScriptDataObject value => value, + _ => throw new AmfException("type of ScriptTagBody.Value is not supported"), + }) + }; + else + throw new AmfException("ScriptTagBody.Name is not String"); + } + + public byte[] ToBytes() + { + using var ms = new MemoryStream(); + this.WriteTo(ms); + return ms.ToArray(); + } + + public void WriteTo(Stream stream) + { + this.Name.WriteTo(stream); + this.Value.WriteTo(stream); + } + + public static IScriptDataValue ParseValue(BigEndianBinaryReader binaryReader) + { + var type = (ScriptDataType)binaryReader.ReadByte(); + switch (type) + { + case ScriptDataType.Number: + return (ScriptDataNumber)binaryReader.ReadDouble(); + case ScriptDataType.Boolean: + return (ScriptDataBoolean)binaryReader.ReadBoolean(); + case ScriptDataType.String: + return ReadScriptDataString(binaryReader, false) ?? string.Empty; + case ScriptDataType.Object: + { + var result = new ScriptDataObject(); + while (true) + { + var propertyName = ReadScriptDataString(binaryReader, true); + if (propertyName is null) + break; + + var propertyData = ParseValue(binaryReader); + + result[propertyName] = propertyData; + } + return result; + } + case ScriptDataType.MovieClip: + throw new AmfException("MovieClip is not supported"); + case ScriptDataType.Null: + return new ScriptDataNull(); + case ScriptDataType.Undefined: + return new ScriptDataUndefined(); + case ScriptDataType.Reference: + return (ScriptDataReference)binaryReader.ReadUInt16(); + case ScriptDataType.EcmaArray: + { + binaryReader.ReadUInt32(); + var result = new ScriptDataEcmaArray(); + while (true) + { + var propertyName = ReadScriptDataString(binaryReader, true); + if (propertyName is null) + break; + + var propertyData = ParseValue(binaryReader); + + result[propertyName] = propertyData; + } + return result; + } + case ScriptDataType.ObjectEndMarker: + throw new AmfException("Read ObjectEndMarker"); + case ScriptDataType.StrictArray: + { + var length = binaryReader.ReadUInt32(); + var result = new ScriptDataStrictArray(); + for (var i = 0; i < length; i++) + { + var value = ParseValue(binaryReader); + result.Add(value); + } + return result; + } + case ScriptDataType.Date: + { + var dateTime = binaryReader.ReadDouble(); + var offset = binaryReader.ReadInt16(); + return new ScriptDataDate(dateTime, offset); + } + case ScriptDataType.LongString: + { + var length = binaryReader.ReadUInt32(); + if (length > int.MaxValue) + throw new AmfException($"LongString larger than {int.MaxValue} is not supported."); + else + { + var bytes = binaryReader.ReadBytes((int)length); + var str = Encoding.UTF8.GetString(bytes); + return (ScriptDataLongString)str; + } + } + default: + throw new AmfException("Unknown ScriptDataValueType"); + } + + static ScriptDataString? ReadScriptDataString(BigEndianBinaryReader binaryReader, bool expectObjectEndMarker) + { + var length = binaryReader.ReadUInt16(); + if (length == 0) + { + if (expectObjectEndMarker && binaryReader.ReadByte() != 9) + throw new AmfException("ObjectEndMarker not matched."); + return null; + } + return Encoding.UTF8.GetString(binaryReader.ReadBytes(length)); + } + } + + XmlSchema IXmlSerializable.GetSchema() => null!; + void IXmlSerializable.ReadXml(XmlReader reader) + { + var str = reader.ReadElementContentAsString(); + var obj = Parse(str); + this.Name = obj.Name; + this.Value = obj.Value; + } + void IXmlSerializable.WriteXml(XmlWriter writer) => writer.WriteString(this.ToJson()); + } +} diff --git a/BililiveRecorder.Flv/BililiveRecorder.Flv.csproj b/BililiveRecorder.Flv/BililiveRecorder.Flv.csproj new file mode 100644 index 0000000..1d9d0e4 --- /dev/null +++ b/BililiveRecorder.Flv/BililiveRecorder.Flv.csproj @@ -0,0 +1,23 @@ + + + + netstandard2.0 + 8.0 + enable + true + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + diff --git a/BililiveRecorder.Flv/DefaultMemoryStreamProvider.cs b/BililiveRecorder.Flv/DefaultMemoryStreamProvider.cs new file mode 100644 index 0000000..ef185ae --- /dev/null +++ b/BililiveRecorder.Flv/DefaultMemoryStreamProvider.cs @@ -0,0 +1,9 @@ +using System.IO; + +namespace BililiveRecorder.Flv +{ + public class DefaultMemoryStreamProvider : IMemoryStreamProvider + { + public Stream CreateMemoryStream(string tag) => new MemoryStream(); + } +} diff --git a/BililiveRecorder.Flv/DependencyInjectionExtensions.cs b/BililiveRecorder.Flv/DependencyInjectionExtensions.cs new file mode 100644 index 0000000..bf304e5 --- /dev/null +++ b/BililiveRecorder.Flv/DependencyInjectionExtensions.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace BililiveRecorder.DependencyInjection +{ + public static class DependencyInjectionExtensions + { + public static IServiceCollection AddFlv(this IServiceCollection services) + { + + return services; + } + } +} diff --git a/BililiveRecorder.Flv/Grouping/Rules/DataGroupingRule.cs b/BililiveRecorder.Flv/Grouping/Rules/DataGroupingRule.cs new file mode 100644 index 0000000..71957a0 --- /dev/null +++ b/BililiveRecorder.Flv/Grouping/Rules/DataGroupingRule.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using BililiveRecorder.Flv.Pipeline; + +namespace BililiveRecorder.Flv.Grouping.Rules +{ + public class DataGroupingRule : IGroupingRule + { + public bool StartWith(Tag tag) => tag.IsData(); + + public bool AppendWith(Tag tag) => tag.IsNonKeyframeData(); + + public PipelineAction CreatePipelineAction(List tags) => new PipelineDataAction(tags); + } +} diff --git a/BililiveRecorder.Flv/Grouping/Rules/HeaderGroupingRule.cs b/BililiveRecorder.Flv/Grouping/Rules/HeaderGroupingRule.cs new file mode 100644 index 0000000..7bbc2cd --- /dev/null +++ b/BililiveRecorder.Flv/Grouping/Rules/HeaderGroupingRule.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using BililiveRecorder.Flv.Pipeline; + +namespace BililiveRecorder.Flv.Grouping.Rules +{ + public class HeaderGroupingRule : IGroupingRule + { + public bool StartWith(Tag tag) => tag.IsHeader(); + + public bool AppendWith(Tag tag) => tag.IsHeader(); + + public PipelineAction CreatePipelineAction(List tags) => new PipelineHeaderAction(tags); + } +} diff --git a/BililiveRecorder.Flv/Grouping/Rules/ScriptGroupingRule.cs b/BililiveRecorder.Flv/Grouping/Rules/ScriptGroupingRule.cs new file mode 100644 index 0000000..2f043fb --- /dev/null +++ b/BililiveRecorder.Flv/Grouping/Rules/ScriptGroupingRule.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Linq; +using BililiveRecorder.Flv.Pipeline; + +namespace BililiveRecorder.Flv.Grouping.Rules +{ + public class ScriptGroupingRule : IGroupingRule + { + public bool StartWith(Tag tag) => tag.IsScript(); + + public bool AppendWith(Tag tag) => false; + + public PipelineAction CreatePipelineAction(List tags) => new PipelineScriptAction(tags.First()); + } +} diff --git a/BililiveRecorder.Flv/Grouping/TagGroupReader.cs b/BililiveRecorder.Flv/Grouping/TagGroupReader.cs new file mode 100644 index 0000000..90fb56d --- /dev/null +++ b/BililiveRecorder.Flv/Grouping/TagGroupReader.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BililiveRecorder.Flv.Grouping.Rules; +using BililiveRecorder.Flv.Pipeline; + +namespace BililiveRecorder.Flv.Grouping +{ + public class TagGroupReader : ITagGroupReader + { + private readonly SemaphoreSlim semaphoreSlim = new SemaphoreSlim(1, 1); + private readonly bool leaveOpen; + private bool disposedValue; + + public IFlvTagReader TagReader { get; } + public IList GroupingRules { get; } + + public TagGroupReader(IFlvTagReader tagReader) + : this(tagReader, false) + { } + + public TagGroupReader(IFlvTagReader flvTagReader, bool leaveOpen = false) + { + this.TagReader = flvTagReader ?? throw new ArgumentNullException(nameof(flvTagReader)); + this.leaveOpen = leaveOpen; + + this.GroupingRules = new List + { + new ScriptGroupingRule(), + new HeaderGroupingRule(), + new DataGroupingRule() + }; + } + + public async Task ReadGroupAsync() + { + if (!this.semaphoreSlim.Wait(0)) + { + throw new InvalidOperationException("Concurrent read is not supported."); + } + try + { + var tags = new List(); + + var firstTag = await this.TagReader.ReadTagAsync().ConfigureAwait(false); + + // 数据已经全部读完 + if (firstTag is null) + return null; + + var rule = this.GroupingRules.FirstOrDefault(x => x.StartWith(firstTag)); + + if (rule is null) + throw new Exception("No grouping rule accepting the tag:" + firstTag.ToString()); + + tags.Add(firstTag); + + while (true) + { + var tag = await this.TagReader.PeekTagAsync().ConfigureAwait(false); + + if (tag != null && rule.AppendWith(tag)) + { + await this.TagReader.ReadTagAsync().ConfigureAwait(false); + tags.Add(tag); + } + else + { + break; + } + } + + return rule.CreatePipelineAction(tags); + } + finally + { + this.semaphoreSlim.Release(); + } + } + + #region Dispose + + protected virtual void Dispose(bool disposing) + { + if (!this.disposedValue) + { + if (disposing) + { + // dispose managed state (managed objects) + if (!this.leaveOpen) + this.TagReader.Dispose(); + } + + // TODO: free unmanaged resources (unmanaged objects) and override finalizer + // TODO: set large fields to null + this.disposedValue = true; + } + } + + // // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources + // ~TagGroupReader() + // { + // // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + // Dispose(disposing: false); + // } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + this.Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + #endregion + } +} diff --git a/BililiveRecorder.Flv/H264Nalu.cs b/BililiveRecorder.Flv/H264Nalu.cs new file mode 100644 index 0000000..2fdf4ae --- /dev/null +++ b/BililiveRecorder.Flv/H264Nalu.cs @@ -0,0 +1,127 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Xml.Serialization; + +namespace BililiveRecorder.Flv +{ + /// + /// H.264 NAL unit + /// + public sealed class H264Nalu + { + private H264Nalu() + { + } + + public H264Nalu(int startPosition, uint fullSize, H264NaluType type) + { + this.StartPosition = startPosition; + this.FullSize = fullSize; + this.Type = type; + } + + public static bool TryParseNalu(Stream data, [NotNullWhen(true)] out List? h264Nalus) + { + h264Nalus = null; + var result = new List(); + var b = new byte[4]; + + data.Seek(5, SeekOrigin.Begin); + + try + { + while (data.Position < data.Length) + { + data.Read(b, 0, 4); + var size = BinaryPrimitives.ReadUInt32BigEndian(b); + if (TryParseNaluType((byte)data.ReadByte(), out var h264NaluType)) + { + var nalu = new H264Nalu((int)(data.Position - 1), size, h264NaluType); + data.Seek(size - 1, SeekOrigin.Current); + result.Add(nalu); + } + else + return false; + } + h264Nalus = result; + return true; + } + catch (Exception) + { + return false; + } + } + + public static bool TryParseNaluType(byte firstByte, out H264NaluType h264NaluType) + { + if ((firstByte & 0b10000000) != 0) + { + h264NaluType = default; + return false; + } + + h264NaluType = (H264NaluType)(firstByte & 0b00011111); + return true; + } + + /// + /// 一个 nal_unit 的开始位置 + /// + [XmlAttribute] + public int StartPosition { get; set; } + + /// + /// 一个 nal_unit 的完整长度 + /// + [XmlAttribute] + public uint FullSize { get; set; } + + /// + /// nal_unit_type + /// + [XmlAttribute] + public H264NaluType Type { get; set; } + } + + /// + /// nal_unit_type + /// + public enum H264NaluType : byte + { + Unspecified0 = 0, + CodedSliceOfANonIdrPicture = 1, + CodedSliceDataPartitionA = 2, + CodedSliceDataPartitionB = 3, + CodedSliceDataPartitionC = 4, + CodedSliceOfAnIdrPicture = 5, + Sei = 6, + Sps = 7, + Pps = 8, + AccessUnitDelimiter = 9, + EndOfSequence = 10, + EndOfStream = 11, + FillerData = 12, + SpsExtension = 13, + PrefixNalUnit = 14, + SubsetSps = 15, + DepthParameterSet = 16, + Reserved17 = 17, + Reserved18 = 18, + SliceLayerWithoutPartitioning = 19, + SliceLayerExtension20 = 20, + SliceLayerExtension21 = 21, + Reserved22 = 22, + Reserved23 = 23, + Unspecified24 = 24, + Unspecified25 = 25, + Unspecified23 = 23, + Unspecified27 = 27, + Unspecified28 = 28, + Unspecified29 = 29, + Unspecified30 = 30, + Unspecified31 = 31, + } +} diff --git a/BililiveRecorder.Flv/IFlvProcessingContextWriter.cs b/BililiveRecorder.Flv/IFlvProcessingContextWriter.cs new file mode 100644 index 0000000..7a1e35e --- /dev/null +++ b/BililiveRecorder.Flv/IFlvProcessingContextWriter.cs @@ -0,0 +1,11 @@ +using System; +using System.Threading.Tasks; +using BililiveRecorder.Flv.Pipeline; + +namespace BililiveRecorder.Flv +{ + public interface IFlvProcessingContextWriter : IDisposable + { + Task WriteAsync(FlvProcessingContext context); + } +} diff --git a/BililiveRecorder.Flv/IFlvTagReader.cs b/BililiveRecorder.Flv/IFlvTagReader.cs new file mode 100644 index 0000000..a677ed7 --- /dev/null +++ b/BililiveRecorder.Flv/IFlvTagReader.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading.Tasks; + +namespace BililiveRecorder.Flv +{ + /// + /// 实现 Flv Tag 的读取 + /// + public interface IFlvTagReader : IDisposable + { + /// + /// Returns the next available Flv Tag but does not consume it. + /// + /// + Task PeekTagAsync(); + + /// + /// Reads the next Flv Tag. + /// + /// + Task ReadTagAsync(); + } +} diff --git a/BililiveRecorder.Flv/IFlvWriterTargetProvider.cs b/BililiveRecorder.Flv/IFlvWriterTargetProvider.cs new file mode 100644 index 0000000..85abec5 --- /dev/null +++ b/BililiveRecorder.Flv/IFlvWriterTargetProvider.cs @@ -0,0 +1,11 @@ +using System.IO; + +namespace BililiveRecorder.Flv +{ + public interface IFlvWriterTargetProvider + { + Stream CreateOutputStream(); + + Stream CreateAlternativeHeaderStream(); + } +} diff --git a/BililiveRecorder.Flv/IGroupingRule.cs b/BililiveRecorder.Flv/IGroupingRule.cs new file mode 100644 index 0000000..4c452df --- /dev/null +++ b/BililiveRecorder.Flv/IGroupingRule.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using BililiveRecorder.Flv.Pipeline; + +namespace BililiveRecorder.Flv +{ + public interface IGroupingRule + { + bool StartWith(Tag tag); + bool AppendWith(Tag tag); + PipelineAction CreatePipelineAction(List tags); + } +} diff --git a/BililiveRecorder.Flv/IMemoryStreamProvider.cs b/BililiveRecorder.Flv/IMemoryStreamProvider.cs new file mode 100644 index 0000000..0c6329b --- /dev/null +++ b/BililiveRecorder.Flv/IMemoryStreamProvider.cs @@ -0,0 +1,9 @@ +using System.IO; + +namespace BililiveRecorder.Flv +{ + public interface IMemoryStreamProvider + { + Stream CreateMemoryStream(string tag); + } +} diff --git a/BililiveRecorder.Flv/ITagGroupReader.cs b/BililiveRecorder.Flv/ITagGroupReader.cs new file mode 100644 index 0000000..f3430bb --- /dev/null +++ b/BililiveRecorder.Flv/ITagGroupReader.cs @@ -0,0 +1,11 @@ +using System; +using System.Threading.Tasks; +using BililiveRecorder.Flv.Pipeline; + +namespace BililiveRecorder.Flv +{ + public interface ITagGroupReader : IDisposable + { + Task ReadGroupAsync(); + } +} diff --git a/BililiveRecorder.Flv/Parser/BigEndianBinaryReader.cs b/BililiveRecorder.Flv/Parser/BigEndianBinaryReader.cs new file mode 100644 index 0000000..8b97168 --- /dev/null +++ b/BililiveRecorder.Flv/Parser/BigEndianBinaryReader.cs @@ -0,0 +1,115 @@ +using System; +using System.Buffers.Binary; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text; + +namespace BililiveRecorder.Flv.Parser +{ + /// + public class BigEndianBinaryReader : BinaryReader + { + /// + public BigEndianBinaryReader(Stream input) : base(input) + { + } + + /// + public BigEndianBinaryReader(Stream input, Encoding encoding) : base(input, encoding) + { + } + + /// + public BigEndianBinaryReader(Stream input, Encoding encoding, bool leaveOpen) : base(input, encoding, leaveOpen) + { + } + + /// + public override Stream BaseStream => base.BaseStream; + + /// + public override void Close() => base.Close(); + + /// + public override bool Equals(object? obj) => base.Equals(obj); + + /// + public override int GetHashCode() => base.GetHashCode(); + + /// + public override int PeekChar() => base.PeekChar(); + + /// + public override int Read() => base.Read(); + + /// + public override int Read(byte[] buffer, int index, int count) => base.Read(buffer, index, count); + + /// + public override int Read(char[] buffer, int index, int count) => base.Read(buffer, index, count); + + /// + public override bool ReadBoolean() => base.ReadBoolean(); + + /// + public override byte ReadByte() => base.ReadByte(); + + /// + public override byte[] ReadBytes(int count) => base.ReadBytes(count); + + /// + public override char ReadChar() => base.ReadChar(); + + /// + public override char[] ReadChars(int count) => base.ReadChars(count); + + /// + public override decimal ReadDecimal() => BitConverter.IsLittleEndian ? throw new NotSupportedException("not supported") : base.ReadDecimal(); + + /// + public override double ReadDouble() => BitConverter.IsLittleEndian + ? BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64BigEndian(base.ReadBytes(sizeof(double)))) + : base.ReadDouble(); + + /// + public override short ReadInt16() => BitConverter.IsLittleEndian ? BinaryPrimitives.ReadInt16BigEndian(base.ReadBytes(sizeof(short))) : base.ReadInt16(); + + /// + public override int ReadInt32() => BitConverter.IsLittleEndian ? BinaryPrimitives.ReadInt32BigEndian(base.ReadBytes(sizeof(int))) : base.ReadInt32(); + + /// + public override long ReadInt64() => BitConverter.IsLittleEndian ? BinaryPrimitives.ReadInt64BigEndian(base.ReadBytes(sizeof(long))) : base.ReadInt64(); + + /// + public override sbyte ReadSByte() => base.ReadSByte(); + + /// + public override float ReadSingle() => BitConverter.IsLittleEndian + ? Int32BitsToSingle(BinaryPrimitives.ReadInt32BigEndian(base.ReadBytes(sizeof(float)))) + : base.ReadSingle(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static unsafe float Int32BitsToSingle(int value) => *(float*)&value; + + /// + public override string ReadString() => base.ReadString(); + + /// + public override ushort ReadUInt16() => BitConverter.IsLittleEndian ? BinaryPrimitives.ReadUInt16BigEndian(base.ReadBytes(sizeof(ushort))) : base.ReadUInt16(); + + /// + public override uint ReadUInt32() => BitConverter.IsLittleEndian ? BinaryPrimitives.ReadUInt32BigEndian(base.ReadBytes(sizeof(uint))) : base.ReadUInt32(); + + /// + public override ulong ReadUInt64() => BitConverter.IsLittleEndian ? BinaryPrimitives.ReadUInt64BigEndian(base.ReadBytes(sizeof(ulong))) : base.ReadUInt64(); + + /// + public override string? ToString() => base.ToString(); + + /// + protected override void Dispose(bool disposing) => base.Dispose(disposing); + + /// + protected override void FillBuffer(int numBytes) => base.FillBuffer(numBytes); + } +} diff --git a/BililiveRecorder.Flv/Parser/FlvException.cs b/BililiveRecorder.Flv/Parser/FlvException.cs new file mode 100644 index 0000000..741b006 --- /dev/null +++ b/BililiveRecorder.Flv/Parser/FlvException.cs @@ -0,0 +1,17 @@ +using System; +using System.Runtime.Serialization; + +namespace BililiveRecorder.Flv.Parser +{ + public class FlvException : Exception + { + /// + public FlvException() { } + /// + public FlvException(string message) : base(message) { } + /// + public FlvException(string message, Exception innerException) : base(message, innerException) { } + /// + protected FlvException(SerializationInfo info, StreamingContext context) : base(info, context) { } + } +} diff --git a/BililiveRecorder.Flv/Parser/FlvTagPipeReader.cs b/BililiveRecorder.Flv/Parser/FlvTagPipeReader.cs new file mode 100644 index 0000000..22a10a9 --- /dev/null +++ b/BililiveRecorder.Flv/Parser/FlvTagPipeReader.cs @@ -0,0 +1,338 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.IO.Pipelines; +using System.Threading; +using System.Threading.Tasks; + +namespace BililiveRecorder.Flv.Parser +{ + /// + /// 从 读取 + /// + public class FlvTagPipeReader : IFlvTagReader, IDisposable + { + private static int memoryCreateCounter = 0; + + private readonly IMemoryStreamProvider memoryStreamProvider; + private readonly bool skipData; + private readonly bool leaveOpen; + + private bool peek = false; + private Tag? peekTag = null; + private readonly SemaphoreSlim peekSemaphoreSlim = new SemaphoreSlim(1, 1); + + private bool fileHeader = false; + + public PipeReader Reader { get; } + + public FlvTagPipeReader(PipeReader reader, IMemoryStreamProvider memoryStreamProvider) : this(reader, memoryStreamProvider, false) { } + + public FlvTagPipeReader(PipeReader reader, IMemoryStreamProvider memoryStreamProvider, bool skipData = false) : this(reader, memoryStreamProvider, skipData, false) { } + + public FlvTagPipeReader(PipeReader reader, IMemoryStreamProvider memoryStreamProvider, bool skipData = false, bool leaveOpen = false) + { + this.Reader = reader ?? throw new ArgumentNullException(nameof(reader)); + + this.memoryStreamProvider = memoryStreamProvider ?? throw new ArgumentNullException(nameof(memoryStreamProvider)); + this.skipData = skipData; + this.leaveOpen = leaveOpen; + } + + public void Dispose() + { + if (!this.leaveOpen) + this.Reader.Complete(); + } + + /// + /// 实现二进制数据的解析 + /// + /// 解析出的 Flv Tag + private async Task ReadNextTagAsync(CancellationToken cancellationToken = default) + { + while (true) + { + var result = await this.Reader.ReadAsync(cancellationToken).ConfigureAwait(false); + var buffer = result.Buffer; + + // In the event that no message is parsed successfully, mark consumed + // as nothing and examined as the entire buffer. + var consumed = buffer.Start; + var examined = buffer.End; + + try + { + if (!this.fileHeader) + { + if (this.ParseFileHeader(ref buffer)) + { + this.fileHeader = true; + consumed = buffer.Start; + examined = consumed; + } + else + continue; + } + + if (this.ParseTagData(ref buffer, out var tag)) + { + // A single message was successfully parsed so mark the start as the + // parsed buffer as consumed. TryParseMessage trims the buffer to + // point to the data after the message was parsed. + consumed = buffer.Start; + + // Examined is marked the same as consumed here, so the next call + // to ReadSingleMessageAsync will process the next message if there's + // one. + examined = consumed; + + return tag; + } + else + { + examined = buffer.End; + } + + if (result.IsCompleted) + { + if (buffer.Length > 0) + { + // The message is incomplete and there's no more data to process. + // throw new FlvException("Incomplete message."); + } + + break; + } + } + finally + { + this.Reader.AdvanceTo(consumed, examined); + } + } + + return null; + } + + private unsafe bool ParseFileHeader(ref ReadOnlySequence buffer) + { + if (buffer.Length < 9) + return false; + + var fileHeaderSlice = buffer.Slice(buffer.Start, 9); + + Span stackSpan = stackalloc byte[9]; + ReadOnlySpan data = stackSpan; + + if (fileHeaderSlice.IsSingleSegment) + data = fileHeaderSlice.First.Span; + else + fileHeaderSlice.CopyTo(stackSpan); + + if (data[0] != 'F' || data[1] != 'L' || data[2] != 'V' || data[3] != 1) + throw new FlvException("Data is not FLV."); + + if (data[5] != 0 || data[6] != 0 || data[7] != 0 || data[8] != 9) + throw new FlvException("Not Supported FLV format."); + + buffer = buffer.Slice(fileHeaderSlice.End); + + return true; + } + + private unsafe bool ParseTagData(ref ReadOnlySequence buffer, [NotNullWhen(true)] out Tag? tag) + { + tag = default; + + if (buffer.Length < 11 + 4) + return false; + + // Slice Tag Header + var tagHeaderSlice = buffer.Slice(4, 11); + buffer = buffer.Slice(tagHeaderSlice.End); + + Span stackTemp = stackalloc byte[4]; + Span stackHeaderSpan = stackalloc byte[11]; + ReadOnlySpan header = stackHeaderSpan; + + if (tagHeaderSlice.IsSingleSegment) + header = tagHeaderSlice.First.Span; + else + tagHeaderSlice.CopyTo(stackHeaderSpan); + + Debug.Assert(header.Length == 11, "Tag header length is not 11."); + + // Read Tag Type + var tagType = (TagType)header[0]; + + switch (tagType) + { + case TagType.Audio: + case TagType.Video: + case TagType.Script: + break; + default: + throw new FlvException("Unexpected Tag Type: " + header[0]); + } + + // Read Tag Size + stackTemp[0] = 0; + stackTemp[1] = header[1]; + stackTemp[2] = header[2]; + stackTemp[3] = header[3]; + var tagSize = BinaryPrimitives.ReadUInt32BigEndian(stackTemp); + + // if not enough data are available + if (buffer.Length < tagSize) + return false; + + // Read Tag Timestamp + stackTemp[1] = header[4]; + stackTemp[2] = header[5]; + stackTemp[3] = header[6]; + stackTemp[0] = header[7]; + var tagTimestamp = BinaryPrimitives.ReadInt32BigEndian(stackTemp); + + // Slice Tag Data + var tagDataSlice = buffer.Slice(buffer.Start, tagSize); + buffer = buffer.Slice(tagDataSlice.End); + + // Copy Tag Data If Required + var tagBodyStream = this.memoryStreamProvider.CreateMemoryStream(nameof(FlvTagPipeReader) + ":TagBody:" + Interlocked.Increment(ref memoryCreateCounter)); + + foreach (var segment in tagDataSlice) + { + var sharedBuffer = ArrayPool.Shared.Rent(segment.Length); + try + { + segment.CopyTo(sharedBuffer); + tagBodyStream.Write(sharedBuffer, 0, segment.Length); + } + finally + { + ArrayPool.Shared.Return(sharedBuffer); + } + } + + // Parse Tag Flag + var tagFlag = TagFlag.None; + + if (tagBodyStream.Length > 2) + { + tagBodyStream.Seek(0, SeekOrigin.Begin); + switch (tagType) + { + case TagType.Audio: + { + var format = tagBodyStream.ReadByte() >> 4; + if (format != 10) // AAC + break; + var packet = tagBodyStream.ReadByte(); + if (packet == 0) + tagFlag = TagFlag.Header; + break; + } + case TagType.Video: + { + var frame = tagBodyStream.ReadByte(); + if ((frame & 0x0F) != 7) // AVC + break; + if (frame == 0x17) + tagFlag |= TagFlag.Keyframe; + var packet = tagBodyStream.ReadByte(); + tagFlag |= packet switch + { + 0 => TagFlag.Header, + 2 => TagFlag.End, + _ => TagFlag.None, + }; + break; + } + default: + break; + } + } + + // Create Tag Object + tag = new Tag + { + Type = tagType, + Flag = tagFlag, + Size = tagSize, + Timestamp = tagTimestamp, + }; + + // Read Tag Type Specific Data + tagBodyStream.Seek(0, SeekOrigin.Begin); + + if (tag.Type == TagType.Script) + { + tag.ScriptData = Amf.ScriptTagBody.Parse(tagBodyStream); + } + else if (tag.Type == TagType.Video && !tag.Flag.HasFlag(TagFlag.Header)) + { + if (H264Nalu.TryParseNalu(tagBodyStream, out var nalus)) + tag.Nalus = nalus; + } + + // Dispose Stream If Not Needed + if (!this.skipData || tag.ShouldSerializeBinaryDataForSerializationUseOnly()) + tag.BinaryData = tagBodyStream; + else + tagBodyStream.Dispose(); + + return true; + } + + /// + public async Task PeekTagAsync() + { + try + { + this.peekSemaphoreSlim.Wait(); + + if (this.peek) + { + return this.peekTag; + } + else + { + this.peekTag = await this.ReadNextTagAsync(); + this.peek = true; + return this.peekTag; + } + } + finally + { + this.peekSemaphoreSlim.Release(); + } + } + + /// + public async Task ReadTagAsync() + { + try + { + this.peekSemaphoreSlim.Wait(); + if (this.peek) + { + var tag = this.peekTag; + this.peekTag = null; + this.peek = false; + return tag; + } + else + { + return await this.ReadNextTagAsync(); + } + } + finally + { + this.peekSemaphoreSlim.Release(); + } + } + } +} diff --git a/BililiveRecorder.Flv/Pipeline/FlvProcessingContext.cs b/BililiveRecorder.Flv/Pipeline/FlvProcessingContext.cs new file mode 100644 index 0000000..2d91ba5 --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/FlvProcessingContext.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; + +namespace BililiveRecorder.Flv.Pipeline +{ + public class FlvProcessingContext + { +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public FlvProcessingContext() + { + } + + public FlvProcessingContext(PipelineAction data, IDictionary sessionItems) + { + this.Reset(data, sessionItems); + } +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + + public PipelineAction OriginalInput { get; private set; } + + public List Output { get; set; } + + public IDictionary SessionItems { get; private set; } + + public IDictionary LocalItems { get; private set; } + + public List Comments { get; private set; } + + public void Reset(PipelineAction data, IDictionary sessionItems) + { + this.OriginalInput = data ?? throw new ArgumentNullException(nameof(data)); + this.SessionItems = sessionItems ?? throw new ArgumentNullException(nameof(sessionItems)); + this.Output = new List { this.OriginalInput.Clone() }; + this.LocalItems = new Dictionary(); + this.Comments = new List(); + } + } + + public static class FlvProcessingContextExtensions + { + public static void AddComment(this FlvProcessingContext context, string comment) + => context.Comments.Add(comment); + + public static void AddNewFileAtStart(this FlvProcessingContext context) + => context.Output.Insert(0, PipelineNewFileAction.Instance); + + public static void AddNewFileAtEnd(this FlvProcessingContext context) + => context.Output.Add(PipelineNewFileAction.Instance); + + public static void AddDisconnectAtStart(this FlvProcessingContext context) + => context.Output.Insert(0, PipelineDisconnectAction.Instance); + + public static void ClearOutput(this FlvProcessingContext context) + => context.Output.Clear(); + } +} diff --git a/BililiveRecorder.Flv/Pipeline/IFullProcessingRule.cs b/BililiveRecorder.Flv/Pipeline/IFullProcessingRule.cs new file mode 100644 index 0000000..8ff1ff1 --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/IFullProcessingRule.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace BililiveRecorder.Flv.Pipeline +{ + public interface IFullProcessingRule : IProcessingRule + { + Task RunAsync(FlvProcessingContext context, ProcessingDelegate next); + } +} diff --git a/BililiveRecorder.Flv/Pipeline/IProcessingPipelineBuilder.cs b/BililiveRecorder.Flv/Pipeline/IProcessingPipelineBuilder.cs new file mode 100644 index 0000000..6be0997 --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/IProcessingPipelineBuilder.cs @@ -0,0 +1,13 @@ +using System; + +namespace BililiveRecorder.Flv.Pipeline +{ + public interface IProcessingPipelineBuilder + { + IServiceProvider ServiceProvider { get; } + + IProcessingPipelineBuilder Add(Func rule); + + ProcessingDelegate Build(); + } +} diff --git a/BililiveRecorder.Flv/Pipeline/IProcessingPipelineBuilderExtensions.cs b/BililiveRecorder.Flv/Pipeline/IProcessingPipelineBuilderExtensions.cs new file mode 100644 index 0000000..09ef3f8 --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/IProcessingPipelineBuilderExtensions.cs @@ -0,0 +1,38 @@ +using System; +using BililiveRecorder.Flv.Pipeline.Rules; +using Microsoft.Extensions.DependencyInjection; + +namespace BililiveRecorder.Flv.Pipeline +{ + public static class IProcessingPipelineBuilderExtensions + { + public static IProcessingPipelineBuilder Add(this IProcessingPipelineBuilder builder) where T : IProcessingRule => + builder.Add(next => (ActivatorUtilities.GetServiceOrCreateInstance(builder.ServiceProvider)) switch + { + ISimpleProcessingRule simple => context => simple.RunAsync(context, () => next(context)), + IFullProcessingRule full => context => full.RunAsync(context, next), + _ => throw new ArgumentException($"Type ({typeof(T).FullName}) does not ISimpleProcessingRule or IFullProcessingRule") + }); + + public static IProcessingPipelineBuilder Add(this IProcessingPipelineBuilder builder, T instance) where T : IProcessingRule => + instance switch + { + ISimpleProcessingRule simple => builder.Add(next => context => simple.RunAsync(context, () => next(context))), + IFullProcessingRule full => builder.Add(next => context => full.RunAsync(context, next)), + _ => throw new ArgumentException($"Type ({typeof(T).FullName}) does not ISimpleProcessingRule or IFullProcessingRule") + }; + + public static IProcessingPipelineBuilder AddDefault(this IProcessingPipelineBuilder builder) => + builder + .Add() + .Add() + .Add() + .Add() + .Add() + .Add() + ; + + public static IProcessingPipelineBuilder AddRemoveFillerData(this IProcessingPipelineBuilder builder) => + builder.Add(); + } +} diff --git a/BililiveRecorder.Flv/Pipeline/IProcessingRule.cs b/BililiveRecorder.Flv/Pipeline/IProcessingRule.cs new file mode 100644 index 0000000..1330178 --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/IProcessingRule.cs @@ -0,0 +1,7 @@ +namespace BililiveRecorder.Flv.Pipeline +{ + public interface IProcessingRule + { + + } +} diff --git a/BililiveRecorder.Flv/Pipeline/ISimpleProcessingRule.cs b/BililiveRecorder.Flv/Pipeline/ISimpleProcessingRule.cs new file mode 100644 index 0000000..28d90ab --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/ISimpleProcessingRule.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading.Tasks; + +namespace BililiveRecorder.Flv.Pipeline +{ + public interface ISimpleProcessingRule : IProcessingRule + { + Task RunAsync(FlvProcessingContext context, Func next); + } +} diff --git a/BililiveRecorder.Flv/Pipeline/PipelineAction.cs b/BililiveRecorder.Flv/Pipeline/PipelineAction.cs new file mode 100644 index 0000000..3ebfced --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/PipelineAction.cs @@ -0,0 +1,7 @@ +namespace BililiveRecorder.Flv.Pipeline +{ + public abstract class PipelineAction + { + public abstract PipelineAction Clone(); + } +} diff --git a/BililiveRecorder.Flv/Pipeline/PipelineDataAction.cs b/BililiveRecorder.Flv/Pipeline/PipelineDataAction.cs new file mode 100644 index 0000000..7e716a6 --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/PipelineDataAction.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace BililiveRecorder.Flv.Pipeline +{ + public class PipelineDataAction : PipelineAction + { + public PipelineDataAction(IList tags) + { + this.Tags = tags ?? throw new ArgumentNullException(nameof(tags)); + } + + public IList Tags { get; set; } + + public override PipelineAction Clone() => new PipelineDataAction(new List(this.Tags)); + } +} diff --git a/BililiveRecorder.Flv/Pipeline/PipelineDisconnectAction.cs b/BililiveRecorder.Flv/Pipeline/PipelineDisconnectAction.cs new file mode 100644 index 0000000..5c1c6c1 --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/PipelineDisconnectAction.cs @@ -0,0 +1,9 @@ +namespace BililiveRecorder.Flv.Pipeline +{ + public class PipelineDisconnectAction : PipelineAction + { + public static readonly PipelineDisconnectAction Instance = new PipelineDisconnectAction(); + + public override PipelineAction Clone() => Instance; + } +} diff --git a/BililiveRecorder.Flv/Pipeline/PipelineHeaderAction.cs b/BililiveRecorder.Flv/Pipeline/PipelineHeaderAction.cs new file mode 100644 index 0000000..53ddd7d --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/PipelineHeaderAction.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace BililiveRecorder.Flv.Pipeline +{ + public class PipelineHeaderAction : PipelineAction + { + public PipelineHeaderAction(IReadOnlyList allTags) + { + this.AllTags = allTags ?? throw new ArgumentNullException(nameof(allTags)); + } + + public Tag? VideoHeader { get; set; } + + public Tag? AudioHeader { get; set; } + + public IReadOnlyList AllTags { get; set; } + + public override PipelineAction Clone() => new PipelineHeaderAction(this.AllTags.ToArray()) + { + VideoHeader = VideoHeader, + AudioHeader = AudioHeader + }; + } +} diff --git a/BililiveRecorder.Flv/Pipeline/PipelineLogAlternativeHeaderAction.cs b/BililiveRecorder.Flv/Pipeline/PipelineLogAlternativeHeaderAction.cs new file mode 100644 index 0000000..f141376 --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/PipelineLogAlternativeHeaderAction.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace BililiveRecorder.Flv.Pipeline +{ + public class PipelineLogAlternativeHeaderAction : PipelineAction + { + public IReadOnlyList Tags { get; set; } + + public PipelineLogAlternativeHeaderAction(IReadOnlyList tags) + { + this.Tags = tags ?? throw new ArgumentNullException(nameof(tags)); + } + + public override PipelineAction Clone() => new PipelineLogAlternativeHeaderAction(this.Tags.ToArray()); + } +} diff --git a/BililiveRecorder.Flv/Pipeline/PipelineNewFileAction.cs b/BililiveRecorder.Flv/Pipeline/PipelineNewFileAction.cs new file mode 100644 index 0000000..76f8584 --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/PipelineNewFileAction.cs @@ -0,0 +1,9 @@ +namespace BililiveRecorder.Flv.Pipeline +{ + public class PipelineNewFileAction : PipelineAction + { + public static readonly PipelineNewFileAction Instance = new PipelineNewFileAction(); + + public override PipelineAction Clone() => Instance; + } +} diff --git a/BililiveRecorder.Flv/Pipeline/PipelineScriptAction.cs b/BililiveRecorder.Flv/Pipeline/PipelineScriptAction.cs new file mode 100644 index 0000000..a8d3089 --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/PipelineScriptAction.cs @@ -0,0 +1,16 @@ +using System; + +namespace BililiveRecorder.Flv.Pipeline +{ + public class PipelineScriptAction : PipelineAction + { + public PipelineScriptAction(Tag tag) + { + this.Tag = tag ?? throw new ArgumentNullException(nameof(tag)); + } + + public Tag Tag { get; set; } + + public override PipelineAction Clone() => new PipelineScriptAction(this.Tag); + } +} diff --git a/BililiveRecorder.Flv/Pipeline/ProcessingDelegate.cs b/BililiveRecorder.Flv/Pipeline/ProcessingDelegate.cs new file mode 100644 index 0000000..18e0295 --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/ProcessingDelegate.cs @@ -0,0 +1,6 @@ +using System.Threading.Tasks; + +namespace BililiveRecorder.Flv.Pipeline +{ + public delegate Task ProcessingDelegate(FlvProcessingContext context); +} diff --git a/BililiveRecorder.Flv/Pipeline/ProcessingPipelineBuilder.cs b/BililiveRecorder.Flv/Pipeline/ProcessingPipelineBuilder.cs new file mode 100644 index 0000000..eaf99f8 --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/ProcessingPipelineBuilder.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace BililiveRecorder.Flv.Pipeline +{ + public class ProcessingPipelineBuilder : IProcessingPipelineBuilder + { + public IServiceProvider ServiceProvider { get; } + + private readonly List> rules = new List>(); + + public ProcessingPipelineBuilder(IServiceProvider serviceProvider) + { + this.ServiceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + } + + public IProcessingPipelineBuilder Add(Func rule) + { + this.rules.Add(rule); + return this; + } + + public ProcessingDelegate Build() + => this.rules.AsEnumerable().Reverse().Aggregate((ProcessingDelegate)(_ => Task.CompletedTask), (i, o) => o(i)); + } +} diff --git a/BililiveRecorder.Flv/Pipeline/Rules/CheckDiscontinuityRule.cs b/BililiveRecorder.Flv/Pipeline/Rules/CheckDiscontinuityRule.cs new file mode 100644 index 0000000..ae11e54 --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/Rules/CheckDiscontinuityRule.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading.Tasks; + +namespace BililiveRecorder.Flv.Pipeline.Rules +{ + /// + /// 检查分块内时间戳问题 + /// + /// + /// 到目前为止还未发现有在一个 GOP 内出现时间戳异常问题
+ /// 本规则是为了预防实际使用中遇到意外情况
+ ///
+ /// 本规则应该放在所有规则前面 + ///
+ public class CheckDiscontinuityRule : ISimpleProcessingRule + { + private const int MAX_ALLOWED_DIFF = 1000 * 10; // 10 seconds + + public Task RunAsync(FlvProcessingContext context, Func next) + { + if (context.OriginalInput is PipelineDataAction data) + { + for (var i = 0; i < data.Tags.Count - 1; i++) + { + var f1 = data.Tags[i]; + var f2 = data.Tags[i + 1]; + + if (f1.Timestamp > f2.Timestamp) + { + context.ClearOutput(); + context.AddDisconnectAtStart(); + context.AddComment("Flv Chunk 内出现时间戳跳变(变小)"); + return Task.CompletedTask; + } + else if ((f2.Timestamp - f1.Timestamp) > MAX_ALLOWED_DIFF) + { + context.ClearOutput(); + context.AddDisconnectAtStart(); + context.AddComment("Flv Chunk 内出现时间戳跳变(间隔过大)"); + return Task.CompletedTask; + } + } + return next(); + } + else return next(); + } + } +} diff --git a/BililiveRecorder.Flv/Pipeline/Rules/CheckMissingKeyframeRule.cs b/BililiveRecorder.Flv/Pipeline/Rules/CheckMissingKeyframeRule.cs new file mode 100644 index 0000000..3388274 --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/Rules/CheckMissingKeyframeRule.cs @@ -0,0 +1,34 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace BililiveRecorder.Flv.Pipeline.Rules +{ + /// + /// 检查缺少关键帧的问题 + /// + /// + /// 到目前为止还未发现有出现过此问题
+ /// 本规则是为了预防实际使用中遇到意外情况
+ ///
+ /// 本规则应该放在所有规则前面 + ///
+ public class CheckMissingKeyframeRule : ISimpleProcessingRule + { + public Task RunAsync(FlvProcessingContext context, Func next) + { + if (context.OriginalInput is PipelineDataAction data) + { + var f = data.Tags.FirstOrDefault(x => x.Type == TagType.Video); + if (f != null && !f.Flag.HasFlag(TagFlag.Keyframe)) + { + context.AddComment("Flv Chunk 内缺少关键帧"); + context.AddDisconnectAtStart(); + return Task.CompletedTask; + } + else return next(); + } + else return next(); + } + } +} diff --git a/BililiveRecorder.Flv/Pipeline/Rules/HandleNewHeaderRule.cs b/BililiveRecorder.Flv/Pipeline/Rules/HandleNewHeaderRule.cs new file mode 100644 index 0000000..76fe65f --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/Rules/HandleNewHeaderRule.cs @@ -0,0 +1,144 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace BililiveRecorder.Flv.Pipeline.Rules +{ + /// + /// 处理收到音视频 Header 的情况 + /// + /// + /// 当收到音视频 Header 时检查与上一组是否相同
+ /// 并根据情况删除重复的 Header 或新建文件写入
+ ///
+ /// 本规则为一般规则 + ///
+ public class HandleNewHeaderRule : ISimpleProcessingRule + { + private const string VIDEO_HEADER_KEY = "HandleNewHeaderRule_VideoHeader"; + private const string AUDIO_HEADER_KEY = "HandleNewHeaderRule_AudioHeader"; + + public Task RunAsync(FlvProcessingContext context, Func next) + { + if (!(context.OriginalInput is PipelineHeaderAction header)) + return next(); + else + { + Tag? lastVideoHeader, lastAudioHeader; + Tag? currentVideoHeader, currentAudioHeader; + var multiple_header_present = false; + + // 从 Session Items 里取上次写入的 Header + lastVideoHeader = context.SessionItems.ContainsKey(VIDEO_HEADER_KEY) ? context.SessionItems[VIDEO_HEADER_KEY] as Tag : null; + lastAudioHeader = context.SessionItems.ContainsKey(AUDIO_HEADER_KEY) ? context.SessionItems[AUDIO_HEADER_KEY] as Tag : null; + + // 音频 视频 分别单独处理 + var group = header.AllTags.GroupBy(x => x.Type); + + { // 音频 + var group_audio = group.FirstOrDefault(x => x.Key == TagType.Audio); + if (group_audio != null) + { + // 检查是否存在 **多个** **不同的** Header + if (group_audio.Count() > 1) + { + var first = group_audio.First(); + + if (group_audio.Skip(1).All(x => first.BinaryData?.SequenceEqual(x.BinaryData) ?? false)) + currentAudioHeader = first; + else + { + // 默认最后一个为正确的 + currentAudioHeader = group_audio.Last(); + multiple_header_present = true; + } + } + else currentAudioHeader = group_audio.FirstOrDefault(); + } + else currentAudioHeader = null; + } + + { // 视频 + var group_video = group.FirstOrDefault(x => x.Key == TagType.Video); + if (group_video != null) + { + // 检查是否存在 **多个** **不同的** Header + if (group_video.Count() > 1) + { + var first = group_video.First(); + + if (group_video.Skip(1).All(x => first.BinaryData?.SequenceEqual(x.BinaryData) ?? false)) + currentVideoHeader = first; + else + { + // 默认最后一个为正确的 + currentVideoHeader = group_video.Last(); + multiple_header_present = true; + } + } + else currentVideoHeader = group_video.FirstOrDefault(); + } + else currentVideoHeader = null; + } + + if (multiple_header_present) + context.AddComment("收到了连续多个 Header"); + + if (currentVideoHeader != null) + context.SessionItems[VIDEO_HEADER_KEY] = currentVideoHeader.Clone(); // TODO use memory provider + if (currentAudioHeader != null) + context.SessionItems[AUDIO_HEADER_KEY] = currentAudioHeader.Clone(); + + if (lastAudioHeader is null && lastVideoHeader is null) + { + // 本 session 第一次输出 header + context.Output.Clear(); + + var output = new PipelineHeaderAction(Array.Empty()) + { + AudioHeader = currentAudioHeader?.Clone(), + VideoHeader = currentVideoHeader?.Clone(), + }; + + context.Output.Add(output); + } + else + { + // 是否需要创建新文件 + // 如果存在多个不同 Header 则必定创建新文件 + var split_file = multiple_header_present; + + // 如果最终选中的 Header 不等于上次写入的 Header + if (lastAudioHeader != null && !(currentAudioHeader?.BinaryData?.SequenceEqual(lastAudioHeader.BinaryData) ?? false)) + split_file = true; + if (lastVideoHeader != null && !(currentVideoHeader?.BinaryData?.SequenceEqual(lastVideoHeader.BinaryData) ?? false)) + split_file = true; + + if (split_file) + context.AddComment("因为 Header 问题新建文件"); + + context.Output.Clear(); + + if (split_file) + { + context.AddNewFileAtStart(); + + var output = new PipelineHeaderAction(Array.Empty()) + { + AudioHeader = currentAudioHeader?.Clone(), + VideoHeader = currentVideoHeader?.Clone(), + }; + + context.Output.Add(output); + + // 输出所有 Header 到一个独立的文件,以防出现无法播放的问题 + // 如果不能正常播放,后期可以使用这里保存的 Header 配合 FlvInteractiveRebase 工具手动修复 + if (multiple_header_present) + context.Output.Add(new PipelineLogAlternativeHeaderAction(header.AllTags)); + } + } + return Task.CompletedTask; + } + } + } +} diff --git a/BililiveRecorder.Flv/Pipeline/Rules/HandleNewScriptRule.cs b/BililiveRecorder.Flv/Pipeline/Rules/HandleNewScriptRule.cs new file mode 100644 index 0000000..b94a1f2 --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/Rules/HandleNewScriptRule.cs @@ -0,0 +1,24 @@ +using System; +using System.Threading.Tasks; + +namespace BililiveRecorder.Flv.Pipeline.Rules +{ + /// + /// 处理收到 Script Tag 的情况 + /// + /// + /// 本规则为一般规则 + /// + public class HandleNewScriptRule : ISimpleProcessingRule + { + public Task RunAsync(FlvProcessingContext context, Func next) + { + if (context.OriginalInput is PipelineScriptAction) + { + context.AddNewFileAtStart(); + return Task.CompletedTask; + } + else return next(); + } + } +} diff --git a/BililiveRecorder.Flv/Pipeline/Rules/RemoveDuplicatedChunkRule.cs b/BililiveRecorder.Flv/Pipeline/Rules/RemoveDuplicatedChunkRule.cs new file mode 100644 index 0000000..aaf8610 --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/Rules/RemoveDuplicatedChunkRule.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BililiveRecorder.Flv; + +namespace BililiveRecorder.Flv.Pipeline.Rules +{ + /// + /// 删除重复的直播数据 + /// + /// + /// 通过对比直播数据的特征,删除重复的直播数据
+ ///
+ /// 本规则为一般规则 + ///
+ public class RemoveDuplicatedChunkRule : ISimpleProcessingRule + { + private const int MAX_HISTORY = 8; + private const string QUEUE_KEY = "DeDuplicationQueue"; + + public Task RunAsync(FlvProcessingContext context, Func next) + { + if (!(context.OriginalInput is PipelineDataAction data)) + return next(); + else + { + var feature = new List(data.Tags.Count * 2 + 1) + { + data.Tags.Count + }; + + unchecked + { + // 计算一个特征码 + // 此处并没有遵循什么特定的算法,只是随便取了一些代表性比较强的值,用简单又尽量可靠的方式糅合到一起而已 + foreach (var tag in data.Tags) + { + var f = 0L; + f ^= tag.Type switch + { + TagType.Audio => 0b01, + TagType.Video => 0b10, + TagType.Script => 0b11, + _ => 0b00, + }; + f <<= 3; + f ^= (int)tag.Flag & ((1 << 3) - 1); + f <<= 32; + f ^= tag.Timestamp; + f <<= 32 - 5; + f ^= tag.Size & ((1 << (32 - 5)) - 1); + feature.Add(f); + + if (tag.Nalus == null) + feature.Add(long.MinValue); + else + { + long n = tag.Nalus.Count << 32; + foreach (var nalu in tag.Nalus) + n ^= (((int)nalu.Type) << 16) ^ ((int)nalu.FullSize); + feature.Add(n); + } + } + } + + // 存储最近 MAX_HISTORY 个 Data Chunk 的特征的 Queue + Queue> history; + if (context.SessionItems.TryGetValue(QUEUE_KEY, out var obj) && obj is Queue> q) + history = q; + else + { + history = new Queue>(MAX_HISTORY + 1); + context.SessionItems[QUEUE_KEY] = history; + } + + // 对比历史特征 + if (history.Any(x => x.SequenceEqual(feature))) + { + context.ClearOutput(); + context.AddComment("发现了重复的 Flv Chunk"); + return Task.CompletedTask; + } + else + { + history.Enqueue(feature); + + while (history.Count > MAX_HISTORY) + history.Dequeue(); + + return next(); + } + } + } + } +} diff --git a/BililiveRecorder.Flv/Pipeline/Rules/RemoveFillerDataRule.cs b/BililiveRecorder.Flv/Pipeline/Rules/RemoveFillerDataRule.cs new file mode 100644 index 0000000..3a7c570 --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/Rules/RemoveFillerDataRule.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; + +namespace BililiveRecorder.Flv.Pipeline.Rules +{ + /// + /// 删除 H.264 Filler Data + /// + /// + /// 部分直播码率瞎填的主播的直播数据中存在大量无用的 Filler Data
+ /// 录制这些主播时删除这些数据可以节省硬盘空间
+ ///
+ /// 本规则应该放在一般规则前面 + ///
+ public class RemoveFillerDataRule : ISimpleProcessingRule + { + public async Task RunAsync(FlvProcessingContext context, Func next) + { + // 先运行下层规则 + await next().ConfigureAwait(false); + + // 处理下层规则返回的数据 + context.Output.ForEach(action => + { + if (action is PipelineDataAction dataAction) + foreach (var tag in dataAction.Tags) + if (tag.Nalus != null) + // 虽然这里处理的是 Output 但是因为与 Input 共享同一个 object 所以会把 Input 一起改掉 + // tag.Nalus = tag.Nalus.Where(x => x.Type != H264NaluType.FillerData).ToList(); + tag.Nalus.RemoveAll(x => x.Type == H264NaluType.FillerData); + }); + + return; + } + } +} diff --git a/BililiveRecorder.Flv/Pipeline/Rules/UpdateTimestampRule.cs b/BililiveRecorder.Flv/Pipeline/Rules/UpdateTimestampRule.cs new file mode 100644 index 0000000..03962bb --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/Rules/UpdateTimestampRule.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace BililiveRecorder.Flv.Pipeline.Rules +{ + public class UpdateTimestampRule : ISimpleProcessingRule + { + private const string TS_STORE_KEY = "Timestamp_Store_Key"; + + private const int JUMP_THRESHOLD = 50; + + private const int AUDIO_DURATION_FALLBACK = 22; + private const int AUDIO_DURATION_MIN = 20; + private const int AUDIO_DURATION_MAX = 24; + + private const int VIDEO_DURATION_FALLBACK = 33; + private const int VIDEO_DURATION_MIN = 15; + private const int VIDEO_DURATION_MAX = 50; + + public async Task RunAsync(FlvProcessingContext context, Func next) + { + await next(); + + var ts = context.SessionItems.ContainsKey(TS_STORE_KEY) ? context.SessionItems[TS_STORE_KEY] as TimestampStore ?? new TimestampStore() : new TimestampStore(); + context.SessionItems[TS_STORE_KEY] = ts; + + foreach (var action in context.Output) + { + if (action is PipelineDataAction dataAction) + { + this.SetDataTimestamp(dataAction.Tags, ts, context); + } + else if (action is PipelineNewFileAction) + { + ts.Reset(); + } + else if (action is PipelineScriptAction s) + { + s.Tag.Timestamp = 0; + ts.Reset(); + } + else if (action is PipelineHeaderAction h) + { + if (h.VideoHeader != null) + h.VideoHeader.Timestamp = 0; + if (h.AudioHeader != null) + h.AudioHeader.Timestamp = 0; + ts.Reset(); + } + } + } + + private void SetDataTimestamp(IList tags, TimestampStore ts, FlvProcessingContext context) + { + // 检查有至少一个音频数据 + // 在 CheckMissingKeyframeRule 已经确认了有视频数据不需要重复检查 + if (!tags.Any(x => x.Type == TagType.Audio)) + { + context.AddComment("未检测到音频数据,跳过时间戳修改"); + return; + } + + var diff = tags[0].Timestamp - ts.LastOriginal; + if (diff < 0) + { + var offsetDiff = this.GetOffsetDiff(tags, ts); + context.AddComment("时间戳问题:变小, Offset Diff: " + offsetDiff); + ts.CurrentOffset += offsetDiff; + } + else if (diff > JUMP_THRESHOLD) + { + var offsetDiff = this.GetOffsetDiff(tags, ts); + context.AddComment("时间戳问题:间隔过大, Offset Diff: " + offsetDiff); + ts.CurrentOffset += offsetDiff; + } + + ts.LastVideoOriginal = tags.Last(x => x.Type == TagType.Video).Timestamp; + ts.LastAudioOriginal = tags.Last(x => x.Type == TagType.Audio).Timestamp; + ts.LastOriginal = Math.Max(ts.LastVideoOriginal, ts.LastAudioOriginal); + + foreach (var tag in tags) + tag.Timestamp -= ts.CurrentOffset; + } + + private int GetOffsetDiff(IList tags, TimestampStore ts) + { + var videoDiff = this.GetAudioOrVideoOffsetDiff(tags.Where(x => x.Type == TagType.Video).Take(2).ToArray(), + ts.LastVideoOriginal, t => t >= VIDEO_DURATION_MIN && t <= VIDEO_DURATION_MAX, VIDEO_DURATION_FALLBACK); + + var audioDiff = this.GetAudioOrVideoOffsetDiff(tags.Where(x => x.Type == TagType.Audio).Take(2).ToArray(), + ts.LastAudioOriginal, t => t >= AUDIO_DURATION_MIN && t <= AUDIO_DURATION_MAX, AUDIO_DURATION_FALLBACK); + + return Math.Min(videoDiff, audioDiff); + } + + private int GetAudioOrVideoOffsetDiff(Tag[] sample, int lastTimestamp, Func validFunc, int fallbackDuration) + { + if (sample.Length <= 1) + return sample[0].Timestamp - lastTimestamp - fallbackDuration; + + var duration = sample[1].Timestamp - sample[0].Timestamp; + + var valid = validFunc(duration); + + if (!valid) + duration = fallbackDuration; + + return sample[0].Timestamp - lastTimestamp - duration; + } + + private class TimestampStore + { + public int LastOriginal; + + public int LastVideoOriginal; + + public int LastAudioOriginal; + + public int CurrentOffset; + + public void Reset() + { + this.LastOriginal = 0; + this.LastVideoOriginal = 0; + this.LastAudioOriginal = 0; + this.CurrentOffset = 0; + } + } + } +} diff --git a/BililiveRecorder.Flv/StreamExtensions.cs b/BililiveRecorder.Flv/StreamExtensions.cs new file mode 100644 index 0000000..856e4b4 --- /dev/null +++ b/BililiveRecorder.Flv/StreamExtensions.cs @@ -0,0 +1,128 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace BililiveRecorder.Flv +{ + internal static class StreamExtensions + { + private const int BUFFER_SIZE = 4 * 1024; + private static readonly ThreadLocal t_buffer = new ThreadLocal(() => new byte[BUFFER_SIZE]); + + internal static async Task SkipBytesAsync(this Stream stream, int length) + { + if (length < 0) { throw new ArgumentOutOfRangeException("length must be non-negative"); } + if (length == 0) { return 0; } + if (null == stream) { throw new ArgumentNullException(nameof(stream)); } + if (!stream.CanRead) { throw new ArgumentException("cannot read stream", nameof(stream)); } + + if (stream.CanSeek) + { + if (stream.Length - stream.Position >= length) + { + stream.Position += length; + return length; + } + return 0; + } + + var buffer = t_buffer.Value!; + var total = 0; + + while (length > BUFFER_SIZE) + { + var read = await stream.ReadAsync(buffer, 0, BUFFER_SIZE); + total += read; + if (read != BUFFER_SIZE) { return total; } + length -= BUFFER_SIZE; + } + + total += await stream.ReadAsync(buffer, 0, length); + return total; + } + + internal static async Task CopyBytesAsync(this Stream from, Stream to, long length) + { + if (null == from) { throw new ArgumentNullException(nameof(from)); } + if (null == to) { throw new ArgumentNullException(nameof(to)); } + if (length < 0) { throw new ArgumentOutOfRangeException("length must be non-negative"); } + if (length == 0) { return true; } + if (!from.CanRead) { throw new ArgumentException("cannot read stream", nameof(from)); } + if (!to.CanWrite) { throw new ArgumentException("cannot write stream", nameof(to)); } + + var buffer = t_buffer.Value!; + + while (length > BUFFER_SIZE) + { + if (BUFFER_SIZE != await from.ReadAsync(buffer, 0, BUFFER_SIZE)) + { + return false; + } + await to.WriteAsync(buffer, 0, BUFFER_SIZE); + length -= BUFFER_SIZE; + } + + if (length != await from.ReadAsync(buffer, 0, (int)length)) + { + return false; + } + + await to.WriteAsync(buffer, 0, (int)length); + + return true; + } + + internal static async Task CopyBytesAsync(this Stream stream, byte[] target) + { + if (null == stream) { throw new ArgumentNullException(nameof(stream)); } + if (null == target) { throw new ArgumentNullException(nameof(target)); } + if (!stream.CanRead) { throw new ArgumentException("cannot read stream", nameof(stream)); } + if (target.Length < 1) return true; + + var head = 0; + while (head < target.Length) + { + var read = await stream.ReadAsync(target, head, target.Length - head); + head += read; + if (read == 0) + return false; + } + + return true; + } + + internal static byte[] ToBE(this byte[] b) + { + if (BitConverter.IsLittleEndian) + { + Array.Reverse(b); + } + return b; + } + + internal static void Write(this Stream stream, byte[] bytes) => stream.Write(bytes, 0, bytes.Length); + + internal static bool SequenceEqual(this Stream self, Stream? other) + { + if (self is null) + throw new ArgumentNullException(nameof(self)); + + if (self == other) + return true; + + if (self.Length != (other?.Length ?? -1)) + return false; + + self.Seek(0, SeekOrigin.Begin); + other!.Seek(0, SeekOrigin.Begin); + + int b; + while ((b = self.ReadByte()) != -1) + if (b != other!.ReadByte()) + return false; + + return true; + } + } +} diff --git a/BililiveRecorder.Flv/Tag.cs b/BililiveRecorder.Flv/Tag.cs new file mode 100644 index 0000000..ba4ebdc --- /dev/null +++ b/BililiveRecorder.Flv/Tag.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Xml.Serialization; +using BililiveRecorder.Flv.Amf; + +namespace BililiveRecorder.Flv +{ + [DebuggerDisplay("{DebuggerDisplay,nq}")] + public sealed class Tag : ICloneable + { + [XmlAttribute] + public TagType Type { get; set; } + + [XmlAttribute] + public TagFlag Flag { get; set; } + + [XmlAttribute] + public uint Size { get; set; } + + [XmlAttribute] + public int Timestamp { get; set; } + + [XmlIgnore] + public Stream? BinaryData { get; set; } + + [XmlElement] + public ScriptTagBody? ScriptData { get; set; } + + [XmlElement] + public List? Nalus { get; set; } + + [XmlElement(nameof(BinaryData))] + public string? BinaryDataForSerializationUseOnly + { + get => this.BinaryData == null + ? null + : BinaryConvertUtilities.StreamToHexString(this.BinaryData); + set + { + Stream? new_stream = null; + if (value != null) + new_stream = BinaryConvertUtilities.HexStringToMemoryStream(value); + + this.BinaryData?.Dispose(); + this.BinaryData = new_stream; + } + } + + public bool ShouldSerializeBinaryDataForSerializationUseOnly() => this.Flag.HasFlag(TagFlag.Header); + + public bool ShouldSerializeScriptData() => this.Type == TagType.Script; + + public bool ShouldSerializeNalus() => this.Type == TagType.Video && !this.Flag.HasFlag(TagFlag.Header); + + public override string ToString() => this.DebuggerDisplay; + + object ICloneable.Clone() => this.Clone(null); + public Tag Clone() => this.Clone(null); + public Tag Clone(IMemoryStreamProvider? provider = null) + { + Stream? binaryData = null; + if (this.BinaryData != null) + { + binaryData = provider?.CreateMemoryStream(nameof(Tag) + ":" + nameof(Clone)) ?? new MemoryStream(); + this.BinaryData.Seek(0, SeekOrigin.Begin); + this.BinaryData.CopyToAsync(binaryData); + } + + ScriptTagBody? scriptData = null; + if (this.ScriptData != null) + { + using var stream = provider?.CreateMemoryStream(nameof(Tag) + ":" + nameof(Clone) + ":Temp") ?? new MemoryStream(); + this.ScriptData.WriteTo(stream); + stream.Seek(0, SeekOrigin.Begin); + scriptData = ScriptTagBody.Parse(stream); + } + + return new Tag + { + Type = this.Type, + Flag = this.Flag, + Size = this.Size, + Timestamp = this.Timestamp, + BinaryData = binaryData, + ScriptData = scriptData, + Nalus = this.Nalus is null ? null : new List(this.Nalus), + }; + } + + private string DebuggerDisplay => string.Format("{0}, {1}{2}{3}, TS={4}, Size={5}", + this.Type switch + { + TagType.Audio => "A", + TagType.Video => "V", + TagType.Script => "S", + _ => "?", + }, + this.Flag.HasFlag(TagFlag.Keyframe) ? "K" : "-", + this.Flag.HasFlag(TagFlag.Header) ? "H" : "-", + this.Flag.HasFlag(TagFlag.End) ? "E" : "-", + this.Timestamp, + this.Size); + + private static class BinaryConvertUtilities + { + private static readonly uint[] _lookup32 = CreateLookup32(); + + private static uint[] CreateLookup32() + { + var result = new uint[256]; + for (var i = 0; i < 256; i++) + { + var s = i.ToString("X2"); + result[i] = s[0] + ((uint)s[1] << 16); + } + return result; + } + + internal static string ByteArrayToHexString(byte[] bytes) + { + var lookup32 = _lookup32; + var result = new char[bytes.Length * 2]; + for (var i = 0; i < bytes.Length; i++) + { + var val = lookup32[bytes[i]]; + result[2 * i] = (char)val; + result[2 * i + 1] = (char)(val >> 16); + } + return new string(result); + } + + internal static byte[] HexStringToByteArray(string hex) + { + var bytes = new byte[hex.Length / 2]; + for (var i = 0; i < hex.Length; i += 2) + bytes[i / 2] = Convert.ToByte(hex.Substring(i, 2), 16); + return bytes; + } + + internal static string StreamToHexString(Stream stream) + { + var lookup32 = _lookup32; + stream.Seek(0, SeekOrigin.Begin); + var result = new char[stream.Length * 2]; + for (var i = 0; i < stream.Length; i++) + { + var val = lookup32[stream.ReadByte()]; + result[2 * i] = (char)val; + result[2 * i + 1] = (char)(val >> 16); + } + return new string(result); + } + + internal static Stream HexStringToMemoryStream(string hex) + { + var stream = new MemoryStream(hex.Length / 2); + for (var i = 0; i < hex.Length; i += 2) + stream.WriteByte(Convert.ToByte(hex.Substring(i, 2), 16)); + return stream; + } + } + } +} diff --git a/BililiveRecorder.Flv/TagExtentions.cs b/BililiveRecorder.Flv/TagExtentions.cs new file mode 100644 index 0000000..946ad38 --- /dev/null +++ b/BililiveRecorder.Flv/TagExtentions.cs @@ -0,0 +1,112 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; + +namespace BililiveRecorder.Flv +{ + public static class TagExtentions + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsScript(this Tag tag) + => tag.Type == TagType.Script; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsHeader(this Tag tag) + => (tag.Type == TagType.Video || tag.Type == TagType.Audio) + && tag.Flag.HasFlag(TagFlag.Header); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsData(this Tag tag) + => tag.Type != TagType.Script; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsNonKeyframeData(this Tag tag) + => (tag.Type == TagType.Video || tag.Type == TagType.Audio) + && tag.Flag == TagFlag.None; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsKeyframeData(this Tag tag) + => tag.Type == TagType.Video && tag.Flag == TagFlag.Keyframe; + + public static async Task WriteTo(this Tag tag, Stream target, int timestamp, IMemoryStreamProvider? memoryStreamProvider = null) + { + var buffer = ArrayPool.Shared.Rent(11); + var dispose = true; + Stream? data = null; + try + { + if (tag.IsScript()) + { + if (tag.ScriptData is null) + throw new Exception("BinaryData is null"); + + data = memoryStreamProvider?.CreateMemoryStream(nameof(TagExtentions) + ":" + nameof(WriteTo) + ":TagBodyTemp") ?? new MemoryStream(); + tag.ScriptData.WriteTo(data); + } + else if (tag.Nalus != null) + { + if (tag.BinaryData is null) + throw new Exception("BinaryData is null"); + + data = memoryStreamProvider?.CreateMemoryStream(nameof(TagExtentions) + ":" + nameof(WriteTo) + ":TagBodyTemp") ?? new MemoryStream(); + + tag.BinaryData.Seek(0, SeekOrigin.Begin); + + tag.BinaryData.Read(buffer, 0, 5); + data.Write(buffer, 0, 5); + + foreach (var nalu in tag.Nalus) + { + BinaryPrimitives.WriteUInt32BigEndian(new Span(buffer, 0, 4), nalu.FullSize); + data.Write(buffer, 0, 4); + + tag.BinaryData.Seek(nalu.StartPosition, SeekOrigin.Begin); + await tag.BinaryData.CopyBytesAsync(data, nalu.FullSize).ConfigureAwait(false); + } + } + else + { + if (tag.BinaryData is null) + throw new Exception("BinaryData is null"); + + dispose = false; + data = tag.BinaryData; + } + + data.Seek(0, SeekOrigin.Begin); + + BinaryPrimitives.WriteUInt32BigEndian(new Span(buffer, 0, 4), (uint)data.Length); + buffer[0] = (byte)tag.Type; + + unsafe + { + var stackTemp = stackalloc byte[4]; + BinaryPrimitives.WriteInt32BigEndian(new Span(stackTemp, 4), timestamp); + buffer[4] = stackTemp[1]; + buffer[5] = stackTemp[2]; + buffer[6] = stackTemp[3]; + buffer[7] = stackTemp[0]; + } + + buffer[8] = 0; + buffer[9] = 0; + buffer[10] = 0; + + await target.WriteAsync(buffer, 0, 11).ConfigureAwait(false); + await data.CopyToAsync(target).ConfigureAwait(false); + + BinaryPrimitives.WriteUInt32BigEndian(new Span(buffer, 0, 4), (uint)data.Length + 11); + await target.WriteAsync(buffer, 0, 4).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(buffer); + if (dispose) + data?.Dispose(); + } + } + } +} diff --git a/BililiveRecorder.Flv/TagFlag.cs b/BililiveRecorder.Flv/TagFlag.cs new file mode 100644 index 0000000..d7fbf6a --- /dev/null +++ b/BililiveRecorder.Flv/TagFlag.cs @@ -0,0 +1,13 @@ +using System; + +namespace BililiveRecorder.Flv +{ + [Flags] + public enum TagFlag : int + { + None = 0, + Header = 1 << 0, + Keyframe = 1 << 1, + End = 1 << 2, + } +} diff --git a/BililiveRecorder.Flv/TagType.cs b/BililiveRecorder.Flv/TagType.cs new file mode 100644 index 0000000..aca5592 --- /dev/null +++ b/BililiveRecorder.Flv/TagType.cs @@ -0,0 +1,10 @@ +namespace BililiveRecorder.Flv +{ + public enum TagType : int + { + Unknown = 0, + Audio = 8, + Video = 9, + Script = 18, + } +} diff --git a/BililiveRecorder.Flv/Writer/FlvProcessingContextWriter.cs b/BililiveRecorder.Flv/Writer/FlvProcessingContextWriter.cs new file mode 100644 index 0000000..fa11ba5 --- /dev/null +++ b/BililiveRecorder.Flv/Writer/FlvProcessingContextWriter.cs @@ -0,0 +1,332 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using BililiveRecorder.Flv.Amf; +using BililiveRecorder.Flv.Pipeline; + +namespace BililiveRecorder.Flv.Writer +{ + public class FlvProcessingContextWriter : IFlvProcessingContextWriter, IDisposable + { + private static readonly byte[] FLV_FILE_HEADER = new byte[] { (byte)'F', (byte)'L', (byte)'V', 1, 0b0000_0101, 0, 0, 0, 9, 0, 0, 0, 0 }; + + private readonly SemaphoreSlim semaphoreSlim = new SemaphoreSlim(1, 1); + private readonly IFlvWriterTargetProvider targetProvider; + private readonly IMemoryStreamProvider memoryStreamProvider; + + private WriterState state = WriterState.EmptyFileOrNotOpen; + + private Stream? stream = null; + + private Tag? nextScriptTag = null; + private Tag? nextAudioHeaderTag = null; + private Tag? nextVideoHeaderTag = null; + + private ScriptTagBody? lastScriptBody = null; + private uint lastScriptBodyLength = 0; + + public Action? BeforeScriptTagWrite { get; set; } + public Action? BeforeScriptTagRewrite { get; set; } + + public FlvProcessingContextWriter(IFlvWriterTargetProvider targetProvider, IMemoryStreamProvider memoryStreamProvider) + { + this.targetProvider = targetProvider ?? throw new ArgumentNullException(nameof(targetProvider)); + this.memoryStreamProvider = memoryStreamProvider ?? throw new ArgumentNullException(nameof(memoryStreamProvider)); + } + + public async Task WriteAsync(FlvProcessingContext context) + { + if (this.state == WriterState.Invalid) + throw new InvalidOperationException("FlvProcessingContextWriter is in a invalid state."); + + // TODO disk speed detection + //if (!await this.semaphoreSlim.WaitAsync(1000 * 5).ConfigureAwait(false)) + //{ + // this.state = WriterState.Invalid; + // throw new InvalidOperationException("WriteAsync Wait timed out."); + //} + + await this.semaphoreSlim.WaitAsync().ConfigureAwait(false); + + try + { + foreach (var item in context.Output) + { + try + { + await this.WriteSingleActionAsync(item).ConfigureAwait(false); + } + catch (Exception) + { + this.state = WriterState.Invalid; + throw; + } + } + } + finally + { + this.semaphoreSlim.Release(); + } + } + + #region Flv Writer Implementation + + private Task WriteSingleActionAsync(PipelineAction action) => action switch + { + PipelineNewFileAction _ => this.OpenNewFile(), + PipelineScriptAction scriptAction => this.WriteScriptTag(scriptAction), + PipelineHeaderAction headerAction => this.WriteHeaderTags(headerAction), + PipelineDataAction dataAction => this.WriteDataTags(dataAction), + PipelineLogAlternativeHeaderAction logAlternativeHeaderAction => this.WriteAlternativeHeader(logAlternativeHeaderAction), + _ => Task.CompletedTask, + }; + + private Task WriteAlternativeHeader(PipelineLogAlternativeHeaderAction logAlternativeHeaderAction) + { + throw new NotImplementedException(); + } + + private async Task OpenNewFile() + { + await this.CloseCurrentFileImpl().ConfigureAwait(false); + // delay open until write + this.state = WriterState.EmptyFileOrNotOpen; + } + + private Task WriteScriptTag(PipelineScriptAction scriptAction) + { + if (scriptAction.Tag != null) + this.nextScriptTag = scriptAction.Tag; + + // delay writing + return Task.CompletedTask; + } + + private Task WriteHeaderTags(PipelineHeaderAction headerAction) + { + if (headerAction.AudioHeader != null) + this.nextAudioHeaderTag = headerAction.AudioHeader; + + if (headerAction.VideoHeader != null) + this.nextVideoHeaderTag = headerAction.VideoHeader; + + // delay writing + return Task.CompletedTask; + } + + private async Task CloseCurrentFileImpl() + { + if (this.stream is null) + return; + + await this.RewriteScriptTagImpl(0).ConfigureAwait(false); + await this.stream.FlushAsync().ConfigureAwait(false); + this.stream.Close(); + this.stream.Dispose(); + this.stream = null; + } + + private async Task OpenNewFileImpl() + { + await this.CloseCurrentFileImpl().ConfigureAwait(false); + + Debug.Assert(this.stream is null, "stream is null"); + + this.stream = this.targetProvider.CreateOutputStream(); + await this.stream.WriteAsync(FLV_FILE_HEADER, 0, FLV_FILE_HEADER.Length).ConfigureAwait(false); + + this.state = WriterState.BeforeScript; + } + + private async Task RewriteScriptTagImpl(double duration) + { + if (this.stream is null || this.lastScriptBody is null) + return; + + this.lastScriptBody.Value["duration"] = (ScriptDataNumber)duration; + this.BeforeScriptTagRewrite?.Invoke(this.lastScriptBody); + + this.stream.Seek(9 + 4 + 11, SeekOrigin.Begin); + + using (var buf = this.memoryStreamProvider.CreateMemoryStream(nameof(FlvProcessingContextWriter) + ":" + nameof(RewriteScriptTagImpl) + ":Temp")) + { + this.lastScriptBody.WriteTo(buf); + if (buf.Length == this.lastScriptBodyLength) + { + buf.Seek(0, SeekOrigin.Begin); + await buf.CopyToAsync(this.stream); + await this.stream.FlushAsync(); + } + else + { + // TODO logging + } + } + + this.stream.Seek(0, SeekOrigin.End); + } + + private async Task WriteScriptTagImpl() + { + if (this.nextScriptTag is null) + throw new InvalidOperationException("No script tag availible"); + + if (this.nextScriptTag.ScriptData is null) + throw new InvalidOperationException("ScriptData is null"); + + if (this.stream is null) + throw new Exception("stream is null"); + + this.lastScriptBody = this.nextScriptTag.ScriptData; + + this.lastScriptBody.Value["duration"] = (ScriptDataNumber)0; + this.BeforeScriptTagWrite?.Invoke(this.lastScriptBody); + + var bytes = ArrayPool.Shared.Rent(11); + try + { + using var bodyStream = this.memoryStreamProvider.CreateMemoryStream(nameof(FlvProcessingContextWriter) + ":" + nameof(WriteScriptTagImpl) + ":Temp"); + this.lastScriptBody.WriteTo(bodyStream); + this.lastScriptBodyLength = (uint)bodyStream.Length; + bodyStream.Seek(0, SeekOrigin.Begin); + + BinaryPrimitives.WriteUInt32BigEndian(new Span(bytes, 0, 4), this.lastScriptBodyLength); + bytes[0] = (byte)TagType.Script; + + bytes[4] = 0; + bytes[5] = 0; + bytes[6] = 0; + bytes[7] = 0; + bytes[8] = 0; + bytes[9] = 0; + bytes[10] = 0; + + await this.stream.WriteAsync(bytes, 0, 11).ConfigureAwait(false); + await bodyStream.CopyToAsync(this.stream); + + BinaryPrimitives.WriteUInt32BigEndian(new Span(bytes, 0, 4), this.lastScriptBodyLength + 11); + await this.stream.WriteAsync(bytes, 0, 4).ConfigureAwait(false); + + await this.stream.FlushAsync(); + } + finally + { + ArrayPool.Shared.Return(bytes); + } + this.state = WriterState.BeforeHeader; + } + + private async Task WriteHeaderTagsImpl() + { + if (this.stream is null) + throw new Exception("stream is null"); + + if (this.nextVideoHeaderTag is null) + throw new InvalidOperationException("No video header tag availible"); + + if (this.nextAudioHeaderTag is null) + throw new InvalidOperationException("No audio header tag availible"); + + await this.nextVideoHeaderTag.WriteTo(this.stream, 0, this.memoryStreamProvider).ConfigureAwait(false); + await this.nextAudioHeaderTag.WriteTo(this.stream, 0, this.memoryStreamProvider).ConfigureAwait(false); + + this.state = WriterState.Writing; + } + + private async Task WriteDataTags(PipelineDataAction dataAction) + { + switch (this.state) + { + case WriterState.EmptyFileOrNotOpen: + await this.OpenNewFileImpl().ConfigureAwait(false); + await this.WriteScriptTagImpl().ConfigureAwait(false); + await this.WriteHeaderTagsImpl().ConfigureAwait(false); + break; + case WriterState.BeforeScript: + await this.WriteScriptTagImpl().ConfigureAwait(false); + await this.WriteHeaderTagsImpl().ConfigureAwait(false); + break; + case WriterState.BeforeHeader: + await this.WriteHeaderTagsImpl().ConfigureAwait(false); + break; + case WriterState.Writing: + break; + default: + throw new InvalidOperationException($"Can't write data tag with current state ({this.state})"); + } + + if (this.stream is null) + throw new Exception("stream is null"); + + foreach (var tag in dataAction.Tags) + await tag.WriteTo(this.stream, tag.Timestamp, this.memoryStreamProvider).ConfigureAwait(false); + + var duration = dataAction.Tags[dataAction.Tags.Count - 1].Timestamp / 1000d; + await this.RewriteScriptTagImpl(duration).ConfigureAwait(false); + } + + #endregion + + #region IDisposable + + private bool disposedValue; + + protected virtual void Dispose(bool disposing) + { + if (!this.disposedValue) + { + if (disposing) + { + // TODO: dispose managed state (managed objects) + } + + // TODO: free unmanaged resources (unmanaged objects) and override finalizer + // TODO: set large fields to null + this.disposedValue = true; + } + } + + // // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources + // ~FlvProcessingContextWriter() + // { + // // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + // Dispose(disposing: false); + // } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + this.Dispose(disposing: true); + GC.SuppressFinalize(this); + } + #endregion + } + + internal enum WriterState + { + /// + /// Invalid + /// + Invalid, + /// + /// 未开文件、空文件、还未写入 FLV Header + /// + EmptyFileOrNotOpen, + /// + /// 已写入 FLV Header、还未写入 Script Tag + /// + BeforeScript, + /// + /// 已写入 Script Tag、还未写入 音视频 Header + /// + BeforeHeader, + /// + /// 已写入音视频 Header、正常写入数据 + /// + Writing, + } +} diff --git a/BililiveRecorder.Flv/Xml/FlvTagListReader.cs b/BililiveRecorder.Flv/Xml/FlvTagListReader.cs new file mode 100644 index 0000000..5207b32 --- /dev/null +++ b/BililiveRecorder.Flv/Xml/FlvTagListReader.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace BililiveRecorder.Flv.Xml +{ + /// + /// 从 读取 Flv Tag + /// + /// + /// 主要在调试修复算法和测试时使用 + /// + public class FlvTagListReader : IFlvTagReader + { + private readonly IReadOnlyList tags; + private int index; + + public FlvTagListReader(IReadOnlyList tags) + { + this.tags = tags ?? throw new ArgumentNullException(nameof(tags)); + } + + public Task PeekTagAsync() => Task.FromResult(this.index < this.tags.Count ? this.tags[this.index] : null)!; + + public Task ReadTagAsync() => Task.FromResult(this.index < this.tags.Count ? this.tags[this.index++] : null); + + public void Dispose() { } + } +} diff --git a/BililiveRecorder.Flv/Xml/XmlFlvFile.cs b/BililiveRecorder.Flv/Xml/XmlFlvFile.cs new file mode 100644 index 0000000..c9a4642 --- /dev/null +++ b/BililiveRecorder.Flv/Xml/XmlFlvFile.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Xml.Serialization; + +namespace BililiveRecorder.Flv.Xml +{ + [XmlRoot("BililiveRecorderFlv")] + public class XmlFlvFile + { + public static XmlSerializer Serializer { get; } = new XmlSerializer(typeof(XmlFlvFile)); + + public List Tags { get; set; } = new List(); + } +} diff --git a/BililiveRecorder.FlvProcessor/BililiveRecorder.FlvProcessor.csproj b/BililiveRecorder.FlvProcessor/BililiveRecorder.FlvProcessor.csproj index b48260f..f718604 100644 --- a/BililiveRecorder.FlvProcessor/BililiveRecorder.FlvProcessor.csproj +++ b/BililiveRecorder.FlvProcessor/BililiveRecorder.FlvProcessor.csproj @@ -22,7 +22,7 @@
- + diff --git a/BililiveRecorder.FlvProcessor/DependencyInjectionExtensions.cs b/BililiveRecorder.FlvProcessor/DependencyInjectionExtensions.cs new file mode 100644 index 0000000..2be7c15 --- /dev/null +++ b/BililiveRecorder.FlvProcessor/DependencyInjectionExtensions.cs @@ -0,0 +1,16 @@ +using System; +using BililiveRecorder.FlvProcessor; +using Microsoft.Extensions.DependencyInjection; + +namespace BililiveRecorder.DependencyInjection +{ + public static class DependencyInjectionExtensions + { + public static void AddFlvProcessor(this IServiceCollection services) + { + services.AddSingleton>(() => new FlvTag()); + services.AddSingleton(); + services.AddSingleton(); + } + } +} diff --git a/BililiveRecorder.FlvProcessor/FlvMetadataFactory.cs b/BililiveRecorder.FlvProcessor/FlvMetadataFactory.cs new file mode 100644 index 0000000..1e49332 --- /dev/null +++ b/BililiveRecorder.FlvProcessor/FlvMetadataFactory.cs @@ -0,0 +1,7 @@ +namespace BililiveRecorder.FlvProcessor +{ + public class FlvMetadataFactory : IFlvMetadataFactory + { + public IFlvMetadata CreateFlvMetadata(byte[] data) => new FlvMetadata(data); + } +} diff --git a/BililiveRecorder.FlvProcessor/FlvProcessorModule.cs b/BililiveRecorder.FlvProcessor/FlvProcessorModule.cs deleted file mode 100644 index fbb0d39..0000000 --- a/BililiveRecorder.FlvProcessor/FlvProcessorModule.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Autofac; - -namespace BililiveRecorder.FlvProcessor -{ - public class FlvProcessorModule : Module - { - public FlvProcessorModule() - { - - } - - protected override void Load(ContainerBuilder builder) - { - builder.Register(c => new FlvTag()).As(); - builder.RegisterType().As(); - builder.RegisterType().As(); - builder.RegisterType().As(); - } - } -} diff --git a/BililiveRecorder.FlvProcessor/FlvStreamProcessor.cs b/BililiveRecorder.FlvProcessor/FlvStreamProcessor.cs index ff8185e..3a94eac 100644 --- a/BililiveRecorder.FlvProcessor/FlvStreamProcessor.cs +++ b/BililiveRecorder.FlvProcessor/FlvStreamProcessor.cs @@ -51,8 +51,8 @@ namespace BililiveRecorder.FlvProcessor public int TotalMaxTimestamp { get; private set; } = 0; public int CurrentMaxTimestamp { get => this.TotalMaxTimestamp - this._writeTimeStamp; } - private readonly Func funcFlvClipProcessor; - private readonly Func funcFlvMetadata; + private readonly IProcessorFactory processorFactory; + private readonly IFlvMetadataFactory flvMetadataFactory; private readonly Func funcFlvTag; private Func<(string fullPath, string relativePath)> GetStreamFileName; @@ -72,13 +72,11 @@ namespace BililiveRecorder.FlvProcessor public IFlvMetadata Metadata { get; set; } = null; public ObservableCollection Clips { get; } = new ObservableCollection(); - public FlvStreamProcessor(Func funcFlvClipProcessor, Func funcFlvMetadata, Func funcFlvTag) + public FlvStreamProcessor(IProcessorFactory processorFactory, IFlvMetadataFactory flvMetadataFactory, Func funcFlvTag) { - this.funcFlvClipProcessor = funcFlvClipProcessor; - this.funcFlvMetadata = funcFlvMetadata; + this.processorFactory = processorFactory; + this.flvMetadataFactory = flvMetadataFactory; this.funcFlvTag = funcFlvTag; - - } public IFlvStreamProcessor Initialize(Func<(string fullPath, string relativePath)> getStreamFileName, Func getClipFileName, EnabledFeature enabledFeature, AutoCuttingMode autoCuttingMode) @@ -335,7 +333,7 @@ namespace BililiveRecorder.FlvProcessor this._targetFile?.Write(FLV_HEADER_BYTES, 0, FLV_HEADER_BYTES.Length); this._targetFile?.Write(new byte[] { 0, 0, 0, 0, }, 0, 4); - this.Metadata = this.funcFlvMetadata(tag.Data); + this.Metadata = this.flvMetadataFactory.CreateFlvMetadata(tag.Data); OnMetaData?.Invoke(this, new FlvMetadataArgs() { Metadata = Metadata }); @@ -361,7 +359,7 @@ namespace BililiveRecorder.FlvProcessor } logger.Info("剪辑处理中,将会保存过去 {0} 秒和将来 {1} 秒的直播流", (this._tags[this._tags.Count - 1].TimeStamp - this._tags[0].TimeStamp) / 1000d, this.ClipLengthFuture); - IFlvClipProcessor clip = this.funcFlvClipProcessor().Initialize(this.GetClipFileName(), this.Metadata, this._headerTags, new List(this._tags.ToArray()), this.ClipLengthFuture); + IFlvClipProcessor clip = this.processorFactory.CreateClipProcessor().Initialize(this.GetClipFileName(), this.Metadata, this._headerTags, new List(this._tags.ToArray()), this.ClipLengthFuture); clip.ClipFinalized += (sender, e) => { this.Clips.Remove(e.ClipProcessor); }; this.Clips.Add(clip); return clip; diff --git a/BililiveRecorder.FlvProcessor/IFlvMetadataFactory.cs b/BililiveRecorder.FlvProcessor/IFlvMetadataFactory.cs new file mode 100644 index 0000000..0714a74 --- /dev/null +++ b/BililiveRecorder.FlvProcessor/IFlvMetadataFactory.cs @@ -0,0 +1,7 @@ +namespace BililiveRecorder.FlvProcessor +{ + public interface IFlvMetadataFactory + { + IFlvMetadata CreateFlvMetadata(byte[] data); + } +} diff --git a/BililiveRecorder.FlvProcessor/IProcessorFactory.cs b/BililiveRecorder.FlvProcessor/IProcessorFactory.cs new file mode 100644 index 0000000..390353d --- /dev/null +++ b/BililiveRecorder.FlvProcessor/IProcessorFactory.cs @@ -0,0 +1,8 @@ +namespace BililiveRecorder.FlvProcessor +{ + public interface IProcessorFactory + { + IFlvClipProcessor CreateClipProcessor(); + IFlvStreamProcessor CreateStreamProcessor(); + } +} diff --git a/BililiveRecorder.FlvProcessor/ProcessorFactory.cs b/BililiveRecorder.FlvProcessor/ProcessorFactory.cs new file mode 100644 index 0000000..b7d3678 --- /dev/null +++ b/BililiveRecorder.FlvProcessor/ProcessorFactory.cs @@ -0,0 +1,20 @@ +using System; + +namespace BililiveRecorder.FlvProcessor +{ + public class ProcessorFactory : IProcessorFactory + { + private readonly Func flvTagFactory; + private readonly IFlvMetadataFactory flvMetadataFactory; + + public ProcessorFactory(Func flvTagFactory, IFlvMetadataFactory flvMetadataFactory) + { + this.flvTagFactory = flvTagFactory ?? throw new ArgumentNullException(nameof(flvTagFactory)); + this.flvMetadataFactory = flvMetadataFactory ?? throw new ArgumentNullException(nameof(flvMetadataFactory)); + } + + public IFlvStreamProcessor CreateStreamProcessor() => new FlvStreamProcessor(this, this.flvMetadataFactory, this.flvTagFactory); + + public IFlvClipProcessor CreateClipProcessor() => new FlvClipProcessor(this.flvTagFactory); + } +} diff --git a/BililiveRecorder.WPF/BililiveRecorder.WPF.csproj b/BililiveRecorder.WPF/BililiveRecorder.WPF.csproj index b08da47..b5b4102 100644 --- a/BililiveRecorder.WPF/BililiveRecorder.WPF.csproj +++ b/BililiveRecorder.WPF/BililiveRecorder.WPF.csproj @@ -280,15 +280,18 @@ - - 4.9.4 - 2.4.3 1.0.8 + + 5.0.1 + + + 5.0.0 + 0.9.2 diff --git a/BililiveRecorder.WPF/Pages/RootPage.xaml.cs b/BililiveRecorder.WPF/Pages/RootPage.xaml.cs index 20f07e7..a7ee942 100644 --- a/BililiveRecorder.WPF/Pages/RootPage.xaml.cs +++ b/BililiveRecorder.WPF/Pages/RootPage.xaml.cs @@ -7,12 +7,12 @@ using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Threading; -using Autofac; using BililiveRecorder.Core; -using BililiveRecorder.FlvProcessor; +using BililiveRecorder.DependencyInjection; using BililiveRecorder.WPF.Controls; using BililiveRecorder.WPF.Models; using CommandLine; +using Microsoft.Extensions.DependencyInjection; using ModernWpf.Controls; using ModernWpf.Media.Animation; using NLog; @@ -31,8 +31,7 @@ namespace BililiveRecorder.WPF.Pages private readonly string lastdir_path = Path.Combine(Path.GetDirectoryName(typeof(RootPage).Assembly.Location), "lastdir.txt"); private readonly NavigationTransitionInfo transitionInfo = new DrillInNavigationTransitionInfo(); - private IContainer Container { get; set; } - private ILifetimeScope RootScope { get; set; } + private ServiceProvider ServiceProvider { get; } private int SettingsClickCount = 0; @@ -50,11 +49,12 @@ namespace BililiveRecorder.WPF.Pages this.Model = new RootModel(); this.DataContext = this.Model; - var builder = new ContainerBuilder(); - builder.RegisterModule(); - builder.RegisterModule(); - this.Container = builder.Build(); - this.RootScope = this.Container.BeginLifetimeScope("recorder_root"); + { + var services = new ServiceCollection(); + services.AddFlvProcessor(); + services.AddCore(); + this.ServiceProvider = services.BuildServiceProvider(); + } this.InitializeComponent(); this.AdvancedSettingsPageItem.Visibility = Visibility.Hidden; @@ -63,7 +63,7 @@ namespace BililiveRecorder.WPF.Pages if (mw is not null) mw.NativeBeforeWindowClose += this.RootPage_NativeBeforeWindowClose; - Loaded += RootPage_Loaded; + Loaded += this.RootPage_Loaded; } private void RootPage_NativeBeforeWindowClose(object sender, EventArgs e) @@ -74,7 +74,7 @@ namespace BililiveRecorder.WPF.Pages private async void RootPage_Loaded(object sender, RoutedEventArgs e) { - var recorder = this.RootScope.Resolve(); + var recorder = this.ServiceProvider.GetRequiredService(); var first_time = true; var error = WorkDirectorySelectorDialog.WorkDirectorySelectorDialogError.None; string path; diff --git a/BililiveRecorder.WPF/packages.config b/BililiveRecorder.WPF/packages.config new file mode 100644 index 0000000..ea156b3 --- /dev/null +++ b/BililiveRecorder.WPF/packages.config @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/BililiveRecorder.sln b/BililiveRecorder.sln index b7db88f..d48c355 100644 --- a/BililiveRecorder.sln +++ b/BililiveRecorder.sln @@ -3,6 +3,15 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.0.29924.181 MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{FD13D9F6-94CD-4CBA-AEA8-EF71002EAC6B}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{2D44A59D-E437-4FEE-8A2E-3FF00D53A64D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{623A2ACC-DAC6-4E6F-9242-B4B54381AAE1}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BililiveRecorder.WPF", "BililiveRecorder.WPF\BililiveRecorder.WPF.csproj", "{0C7D4236-BF43-4944-81FE-E07E05A3F31D}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BililiveRecorder.Core", "BililiveRecorder.Core\BililiveRecorder.Core.csproj", "{CB9F2D58-181D-49F7-9560-D35A9B9C1D8C}" @@ -14,15 +23,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BililiveRecorder.FlvProcess EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BililiveRecorder.Cli", "BililiveRecorder.Cli\BililiveRecorder.Cli.csproj", "{1B626335-283F-4313-9045-B5B96FAAB2DF}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{FD13D9F6-94CD-4CBA-AEA8-EF71002EAC6B}" - ProjectSection(SolutionItems) = preProject - .editorconfig = .editorconfig - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{623A2ACC-DAC6-4E6F-9242-B4B54381AAE1}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BililiveRecorder.Flv", "BililiveRecorder.Flv\BililiveRecorder.Flv.csproj", "{7610E19C-D3AB-4CBC-983E-6FDA36F4D4B3}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BililiveRecorder.UnitTest.Core", "test\BililiveRecorder.UnitTest.Core\BililiveRecorder.UnitTest.Core.csproj", "{521EC763-5694-45A8-B87F-6E6B7F2A3BD4}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BililiveRecorder.Flv.UnitTests", "test\BililiveRecorder.Flv.UnitTests\BililiveRecorder.Flv.UnitTests.csproj", "{560E8483-9293-410E-81E9-AB36B49F8A7C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -45,21 +51,36 @@ Global {1B626335-283F-4313-9045-B5B96FAAB2DF}.Debug|Any CPU.Build.0 = Debug|Any CPU {1B626335-283F-4313-9045-B5B96FAAB2DF}.Release|Any CPU.ActiveCfg = Release|Any CPU {1B626335-283F-4313-9045-B5B96FAAB2DF}.Release|Any CPU.Build.0 = Release|Any CPU + {7610E19C-D3AB-4CBC-983E-6FDA36F4D4B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7610E19C-D3AB-4CBC-983E-6FDA36F4D4B3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7610E19C-D3AB-4CBC-983E-6FDA36F4D4B3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7610E19C-D3AB-4CBC-983E-6FDA36F4D4B3}.Release|Any CPU.Build.0 = Release|Any CPU {521EC763-5694-45A8-B87F-6E6B7F2A3BD4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {521EC763-5694-45A8-B87F-6E6B7F2A3BD4}.Debug|Any CPU.Build.0 = Debug|Any CPU {521EC763-5694-45A8-B87F-6E6B7F2A3BD4}.Release|Any CPU.ActiveCfg = Release|Any CPU {521EC763-5694-45A8-B87F-6E6B7F2A3BD4}.Release|Any CPU.Build.0 = Release|Any CPU + {560E8483-9293-410E-81E9-AB36B49F8A7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {560E8483-9293-410E-81E9-AB36B49F8A7C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {560E8483-9293-410E-81E9-AB36B49F8A7C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {560E8483-9293-410E-81E9-AB36B49F8A7C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution + {0C7D4236-BF43-4944-81FE-E07E05A3F31D} = {2D44A59D-E437-4FEE-8A2E-3FF00D53A64D} + {CB9F2D58-181D-49F7-9560-D35A9B9C1D8C} = {2D44A59D-E437-4FEE-8A2E-3FF00D53A64D} + {51748048-1949-4218-8DED-94014ABE7633} = {2D44A59D-E437-4FEE-8A2E-3FF00D53A64D} + {1B626335-283F-4313-9045-B5B96FAAB2DF} = {2D44A59D-E437-4FEE-8A2E-3FF00D53A64D} + {7610E19C-D3AB-4CBC-983E-6FDA36F4D4B3} = {2D44A59D-E437-4FEE-8A2E-3FF00D53A64D} {521EC763-5694-45A8-B87F-6E6B7F2A3BD4} = {623A2ACC-DAC6-4E6F-9242-B4B54381AAE1} + {560E8483-9293-410E-81E9-AB36B49F8A7C} = {623A2ACC-DAC6-4E6F-9242-B4B54381AAE1} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution - RESX_SortFileContentOnSave = True - SolutionGuid = {F3CB8B14-077A-458F-BD8E-1747ED0F5170} - RESX_NeutralResourcesLanguage = zh-Hans + RESX_ShowErrorsInErrorList = False RESX_SaveFilesImmediatelyUponChange = True + RESX_NeutralResourcesLanguage = zh-Hans + SolutionGuid = {F3CB8B14-077A-458F-BD8E-1747ED0F5170} + RESX_SortFileContentOnSave = True EndGlobalSection EndGlobal diff --git a/test/BililiveRecorder.Flv.UnitTests/Amf/AmfTests.cs b/test/BililiveRecorder.Flv.UnitTests/Amf/AmfTests.cs new file mode 100644 index 0000000..b922a7f --- /dev/null +++ b/test/BililiveRecorder.Flv.UnitTests/Amf/AmfTests.cs @@ -0,0 +1,89 @@ +using System; +using BililiveRecorder.Flv.Amf; +using FluentAssertions; +using Xunit; + +namespace BililiveRecorder.Flv.UnitTests.Amf +{ + public class AmfTests + { + private static ScriptTagBody CreateTestObject() => new ScriptTagBody + { + Name = "test", + Value = new ScriptDataObject + { + ["bool_true"] = (ScriptDataBoolean)true, + ["bool_false"] = (ScriptDataBoolean)false, + ["date1"] = (ScriptDataDate)DateTimeOffset.FromUnixTimeMilliseconds(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()), // remove extra precision + ["date2"] = (ScriptDataDate)new DateTimeOffset(2345, 3, 14, 7, 8, 9, 12, TimeSpan.FromHours(4)), + ["ecmaarray"] = new ScriptDataEcmaArray + { + ["element1"] = (ScriptDataString)"element1", + ["element2"] = (ScriptDataString)"element2", + ["element3"] = (ScriptDataString)"element3", + }, + ["longstring1"] = (ScriptDataLongString)"longstring1", + ["longstring2"] = (ScriptDataLongString)"longstring2", + ["null"] = new ScriptDataNull(), + ["number1"] = (ScriptDataNumber)0, + ["number2"] = (ScriptDataNumber)197653.845, + ["number3"] = (ScriptDataNumber)(-95.7), + ["number4"] = (ScriptDataNumber)double.Epsilon, + ["strictarray"] = new ScriptDataStrictArray + { + (ScriptDataString)"element1", + (ScriptDataString)"element2", + (ScriptDataString)"element3", + }, + ["string1"] = (ScriptDataString)"string1", + ["string2"] = (ScriptDataString)"string2", + ["undefined"] = new ScriptDataUndefined(), + }, + }; + + [Fact] + public void EqualAfterJsonSerialization() + { + var body = CreateTestObject(); + var json = body.ToJson(); + var body2 = ScriptTagBody.Parse(json); + var json2 = body2.ToJson(); + + body2.Should().BeEquivalentTo(body, options => options.RespectingRuntimeTypes()); + json2.Should().Be(json); + } + + [Fact] + public void EqualAfterBinarySerialization() + { + var body = CreateTestObject(); + var bytes = body.ToBytes(); + var body2 = ScriptTagBody.Parse(bytes); + var bytes2 = body2.ToBytes(); + + body2.Should().BeEquivalentTo(body, options => options.RespectingRuntimeTypes()); + bytes2.Should().BeEquivalentTo(bytes2, options => options.RespectingRuntimeTypes()); + } + + [Fact] + public void EqualAfterMixedSerialization() + { + var original = CreateTestObject(); + + var a_json = original.ToJson(); + var a_body = ScriptTagBody.Parse(a_json); + var a_byte = a_body.ToBytes(); + + var b_byte = original.ToBytes(); + var b_body = ScriptTagBody.Parse(b_byte); + var b_json = b_body.ToJson(); + + b_json.Should().Be(a_json); + a_byte.Should().BeEquivalentTo(b_byte); + + a_body.Should().BeEquivalentTo(original, options => options.RespectingRuntimeTypes()); + b_body.Should().BeEquivalentTo(original, options => options.RespectingRuntimeTypes()); + a_body.Should().BeEquivalentTo(b_body, options => options.RespectingRuntimeTypes()); + } + } +} diff --git a/test/BililiveRecorder.Flv.UnitTests/BililiveRecorder.Flv.UnitTests.csproj b/test/BililiveRecorder.Flv.UnitTests/BililiveRecorder.Flv.UnitTests.csproj new file mode 100644 index 0000000..2d6a8f5 --- /dev/null +++ b/test/BililiveRecorder.Flv.UnitTests/BililiveRecorder.Flv.UnitTests.csproj @@ -0,0 +1,30 @@ + + + + netcoreapp3.1 + + false + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/test/BililiveRecorder.Flv.UnitTests/Flv/ParsingTest.cs b/test/BililiveRecorder.Flv.UnitTests/Flv/ParsingTest.cs new file mode 100644 index 0000000..f0e373d --- /dev/null +++ b/test/BililiveRecorder.Flv.UnitTests/Flv/ParsingTest.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.IO; +using System.IO.Pipelines; +using System.Threading.Tasks; +using BililiveRecorder.Flv.Parser; +using BililiveRecorder.Flv.Xml; +using JetBrains.dotMemoryUnit; +using Xunit; +using Xunit.Abstractions; + +namespace BililiveRecorder.Flv.UnitTests.Flv +{ + public class ParsingTest + { + private readonly ITestOutputHelper _output; + + public ParsingTest(ITestOutputHelper output) + { + this._output = output; + DotMemoryUnitTestOutput.SetOutputMethod(this._output.WriteLine); + } + + // [AssertTraffic(AllocatedSizeInBytes = 1000)] + [Fact(Skip = "Not ready")] + public async Task Run() + { + var path = @""; + + var tags = new List(); + + var reader = new FlvTagPipeReader(PipeReader.Create(File.OpenRead(path)), new TestRecyclableMemoryStreamProvider(), skipData: true); + + while (true) + { + var tag = await reader.ReadTagAsync().ConfigureAwait(false); + + if (tag is null) + break; + + tags.Add(tag); + } + + var xmlObj = new XmlFlvFile + { + Tags = tags + }; + + var writer = new StringWriter(); + XmlFlvFile.Serializer.Serialize(writer, xmlObj); + + var xmlStr = writer.ToString(); + + //var peakWorkingSet = Process.GetCurrentProcess().PeakWorkingSet64; + //throw new System.Exception("PeakWorkingSet64: " + peakWorkingSet); + } + } +} diff --git a/test/BililiveRecorder.Flv.UnitTests/Grouping/GroupingTest.cs b/test/BililiveRecorder.Flv.UnitTests/Grouping/GroupingTest.cs new file mode 100644 index 0000000..31e7015 --- /dev/null +++ b/test/BililiveRecorder.Flv.UnitTests/Grouping/GroupingTest.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Pipelines; +using System.Linq; +using System.Threading.Tasks; +using BililiveRecorder.Flv.Grouping; +using BililiveRecorder.Flv.Parser; +using BililiveRecorder.Flv.Pipeline; +using BililiveRecorder.Flv.Writer; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace BililiveRecorder.Flv.UnitTests.Grouping +{ + public class GroupingTest + { + private const string TEST_OUTPUT_PATH = @""; + + public class TestOutputProvider : IFlvWriterTargetProvider + { + public Stream CreateAlternativeHeaderStream() => throw new NotImplementedException(); + public Stream CreateOutputStream() => File.Open(Path.Combine(TEST_OUTPUT_PATH, DateTimeOffset.Now.ToString("s").Replace(':', '-') + ".flv"), FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None); + } + + [Fact(Skip = "Not ready")] + public async Task Test1Async() + { + var path = @""; + + var results = new List(); + + var grouping = new TagGroupReader(new FlvTagPipeReader(PipeReader.Create(File.OpenRead(path)), new TestRecyclableMemoryStreamProvider(), skipData: true)); + + var context = new FlvProcessingContext(); + var session = new Dictionary(); + + var sp = new ServiceCollection().BuildServiceProvider(); + var pipeline = new ProcessingPipelineBuilder(sp).AddDefault().AddRemoveFillerData().Build(); + + while (true) + { + var g = await grouping.ReadGroupAsync().ConfigureAwait(false); + + if (g is null) + break; + + context.Reset(g, session); + + await pipeline(context); + + foreach (var item in context.Output) + { + results.Add(item); + } + } + + var sizes = results.Select(a => a switch + { + PipelineDataAction x => x.Tags.Sum(b => b.Size), + PipelineHeaderAction x => x.AllTags.Sum(b => b.Size), + PipelineScriptAction x => x.Tag.Size, + _ => 0, + } + ).ToArray(); + } + + [Fact(Skip = "Not ready")] + public async Task Test2Async() + { + const string PATH = @""; + + using var grouping = new TagGroupReader(new FlvTagPipeReader(PipeReader.Create(File.OpenRead(PATH)), new TestRecyclableMemoryStreamProvider(), skipData: false)); + + var comments = new List(); + + var context = new FlvProcessingContext(); + var session = new Dictionary(); + + var sp = new ServiceCollection().BuildServiceProvider(); + var pipeline = new ProcessingPipelineBuilder(sp).AddDefault().AddRemoveFillerData().Build(); + + using var writer = new FlvProcessingContextWriter(new TestOutputProvider(), new TestRecyclableMemoryStreamProvider()); + + while (true) + { + var g = await grouping.ReadGroupAsync().ConfigureAwait(false); + + if (g is null) + break; + + context.Reset(g, session); + + await pipeline(context); + + comments.AddRange(context.Comments); + await writer.WriteAsync(context).ConfigureAwait(false); + + foreach (var action in context.Output) + { + // TODO action.Dispose(); + + if (action is PipelineDataAction dataAction) + { + foreach (var tag in dataAction.Tags) + { + tag.BinaryData?.Dispose(); + } + } + } + } + } + } +} diff --git a/test/BililiveRecorder.Flv.UnitTests/TestRecyclableMemoryStreamProvider.cs b/test/BililiveRecorder.Flv.UnitTests/TestRecyclableMemoryStreamProvider.cs new file mode 100644 index 0000000..9359c68 --- /dev/null +++ b/test/BililiveRecorder.Flv.UnitTests/TestRecyclableMemoryStreamProvider.cs @@ -0,0 +1,17 @@ +using System.IO; +using Microsoft.IO; + +namespace BililiveRecorder.Flv.UnitTests +{ + public class TestRecyclableMemoryStreamProvider : IMemoryStreamProvider + { + private static readonly RecyclableMemoryStreamManager manager + = new RecyclableMemoryStreamManager(32 * 1024, 64 * 1024, 64 * 1024 * 32) + { + MaximumFreeSmallPoolBytes = 64 * 1024 * 1024, + MaximumFreeLargePoolBytes = 64 * 1024 * 32, + }; + + public Stream CreateMemoryStream(string tag) => manager.GetStream(tag); + } +} diff --git a/test/BililiveRecorder.Flv.UnitTests/Xml/XmlTests.cs b/test/BililiveRecorder.Flv.UnitTests/Xml/XmlTests.cs new file mode 100644 index 0000000..b916348 --- /dev/null +++ b/test/BililiveRecorder.Flv.UnitTests/Xml/XmlTests.cs @@ -0,0 +1,125 @@ +using System.Collections.Generic; +using System.IO; +using System.IO.Pipelines; +using System.Threading.Tasks; +using System.Xml.Serialization; +using BililiveRecorder.Flv.Amf; +using BililiveRecorder.Flv.Parser; +using BililiveRecorder.Flv.Xml; +using Xunit; + +namespace BililiveRecorder.Flv.UnitTests.Xml +{ + public class XmlTests + { + [Fact] + public void Test1() + { + // TODO cleanup + + var source = new XmlFlvFile + { + Tags = new List + { + new Tag + { + Type = TagType.Script, + Size=4321, + ScriptData = new ScriptTagBody + { + Name = "test1", + Value = new ScriptDataObject + { + ["key1"] = (ScriptDataNumber)5, + ["key2"] = (ScriptDataString)"testTest" + } + } + }, + new Tag + { + Type = TagType.Audio, + ScriptData = new ScriptTagBody + { + Name = "test2", + Value = new ScriptDataObject + { + ["key1"] = (ScriptDataNumber)5, + ["key2"] = (ScriptDataString)"testTest" + } + } + }, + new Tag + { + Type = TagType.Audio, + Flag = TagFlag.Header, + BinaryData = new MemoryStream(new byte[]{0,1,2,3,4,5,6,7}) + }, + new Tag + { + Type = TagType.Video, + Flag = TagFlag.Header | TagFlag.Keyframe + }, + new Tag + { + Type = TagType.Video, + Nalus = new List + { + new H264Nalu(0,156, H264NaluType.CodedSliceDataPartitionB), + new H264Nalu(198,13216, H264NaluType.Pps), + new H264Nalu(432154,432156, H264NaluType.FillerData), + } + } + } + }; + + var serializer = new XmlSerializer(typeof(XmlFlvFile)); + + var writer1 = new StringWriter(); + serializer.Serialize(writer1, source); + var str1 = writer1.ToString(); + + var obj1 = serializer.Deserialize(new StringReader(str1)); + + var writer2 = new StringWriter(); + serializer.Serialize(writer2, obj1); + var str2 = writer2.ToString(); + + var obj2 = serializer.Deserialize(new StringReader(str1)); + + var writer3 = new StringWriter(); + serializer.Serialize(writer3, obj2); + var str3 = writer3.ToString(); + + Assert.Equal(str1, str2); + Assert.Equal(str2, str3); + } + + [Fact(Skip = "Not ready")] + public async Task Test2Async() + { + var PATH = @""; + + var reader = new FlvTagPipeReader(PipeReader.Create(File.OpenRead(PATH)), new TestRecyclableMemoryStreamProvider(), skipData: true); + + var source = new XmlFlvFile + { + Tags = new List() + }; + + while (true) + { + var tag = await reader.ReadTagAsync().ConfigureAwait(false); + + if (tag is null) + break; + + source.Tags.Add(tag); + } + + var writer1 = new StringWriter(); + XmlFlvFile.Serializer.Serialize(writer1, source); + var str1 = writer1.ToString(); + + } + } +} diff --git a/test/BililiveRecorder.UnitTest.Core/BililiveRecorder.UnitTest.Core.csproj b/test/BililiveRecorder.UnitTest.Core/BililiveRecorder.UnitTest.Core.csproj index 838dc17..c78087f 100644 --- a/test/BililiveRecorder.UnitTest.Core/BililiveRecorder.UnitTest.Core.csproj +++ b/test/BililiveRecorder.UnitTest.Core/BililiveRecorder.UnitTest.Core.csproj @@ -8,13 +8,13 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all