diff --git a/BililiveRecorder.Core/BililiveRecorder.Core.csproj b/BililiveRecorder.Core/BililiveRecorder.Core.csproj index 3a54e3f..144ff55 100644 --- a/BililiveRecorder.Core/BililiveRecorder.Core.csproj +++ b/BililiveRecorder.Core/BililiveRecorder.Core.csproj @@ -8,6 +8,7 @@ + diff --git a/BililiveRecorder.Core/Config/ConfigMapper.cs b/BililiveRecorder.Core/Config/ConfigMapper.cs index d850621..8a4f459 100644 --- a/BililiveRecorder.Core/Config/ConfigMapper.cs +++ b/BililiveRecorder.Core/Config/ConfigMapper.cs @@ -86,12 +86,19 @@ namespace BililiveRecorder.Core.Config // 如果用户设置了自定义的文件名格式才需要转换,否则使用全局默认 if (v2.Global.HasRecordFilenameFormat && v2.Global.RecordFilenameFormat is not null) { - v3.Global.FileNameRecordTemplate = v2.Global.RecordFilenameFormat.Replace("", ""); // TODO - + v3.Global.FileNameRecordTemplate = v2.Global.RecordFilenameFormat + .Replace("{date}", "{{ \"now\" | format_date: \"yyyyMMdd\" }}") + .Replace("{time}", "{{ \"now\" | format_date: \"HHmmss\" }}") + .Replace("{ms}", "{{ \"now\" | format_date: \"fff\" }}") + .Replace("{random}", "{% random 3 %}") + .Replace("{roomid}", "{{ roomId }}") + .Replace("{title}", "{{ title }}") + .Replace("{name}", "{{ name }}") + .Replace("{parea}", "{{ areaParent }}") + .Replace("{area}", "{{ areaChild }}") + ; } - throw new NotImplementedException("配置转换还未完成"); - return v3; } diff --git a/BililiveRecorder.Core/Config/V3/Config.gen.cs b/BililiveRecorder.Core/Config/V3/Config.gen.cs index 8b9d58a..c5e2d3a 100644 --- a/BililiveRecorder.Core/Config/V3/Config.gen.cs +++ b/BililiveRecorder.Core/Config/V3/Config.gen.cs @@ -372,7 +372,7 @@ namespace BililiveRecorder.Core.Config.V3 public string RecordingQuality => "10000"; - public string FileNameRecordTemplate => @"{roomid}-{name}/录制-{roomid}-{date}-{time}-{ms}-{title}.flv"; + public string FileNameRecordTemplate => "{{ roomId }}-{{ name }}/录制-{{ roomId }}-{{ \"now\" | format_date: \"yyyyMMdd-HHmmss-fff\" }}-{{ title }}.flv"; public string WebHookUrls => string.Empty; diff --git a/BililiveRecorder.Core/DependencyInjectionExtensions.cs b/BililiveRecorder.Core/DependencyInjectionExtensions.cs index f92208f..38914aa 100644 --- a/BililiveRecorder.Core/DependencyInjectionExtensions.cs +++ b/BililiveRecorder.Core/DependencyInjectionExtensions.cs @@ -5,6 +5,7 @@ using BililiveRecorder.Core.Api.Http; using BililiveRecorder.Core.Config.V3; using BililiveRecorder.Core.Danmaku; using BililiveRecorder.Core.Recording; +using BililiveRecorder.Core.Templating; using BililiveRecorder.Flv; using Microsoft.Extensions.DependencyInjection; using Polly.Registry; @@ -42,6 +43,7 @@ namespace BililiveRecorder.DependencyInjection ; public static IServiceCollection AddRecorderRecording(this IServiceCollection services) => services + .AddSingleton() .AddScoped() .AddScoped() .AddScoped() diff --git a/BililiveRecorder.Core/Recording/RawDataRecordTask.cs b/BililiveRecorder.Core/Recording/RawDataRecordTask.cs index ee184c6..c01ffbf 100644 --- a/BililiveRecorder.Core/Recording/RawDataRecordTask.cs +++ b/BililiveRecorder.Core/Recording/RawDataRecordTask.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using BililiveRecorder.Core.Api; using BililiveRecorder.Core.Event; +using BililiveRecorder.Core.Templating; using Serilog; namespace BililiveRecorder.Core.Recording @@ -14,10 +15,12 @@ namespace BililiveRecorder.Core.Recording public RawDataRecordTask(IRoom room, ILogger logger, - IApiClient apiClient) + IApiClient apiClient, + FileNameGenerator fileNameGenerator) : base(room: room, logger: logger?.ForContext().ForContext(LoggingContext.RoomId, room.RoomConfig.RoomId)!, - apiClient: apiClient) + apiClient: apiClient, + fileNameGenerator: fileNameGenerator) { } diff --git a/BililiveRecorder.Core/Recording/RecordTaskBase.cs b/BililiveRecorder.Core/Recording/RecordTaskBase.cs index f443183..8e11d45 100644 --- a/BililiveRecorder.Core/Recording/RecordTaskBase.cs +++ b/BililiveRecorder.Core/Recording/RecordTaskBase.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using System.Timers; using BililiveRecorder.Core.Api; using BililiveRecorder.Core.Event; +using BililiveRecorder.Core.Templating; using Serilog; using Timer = System.Timers.Timer; @@ -28,6 +29,7 @@ namespace BililiveRecorder.Core.Recording protected readonly IRoom room; protected readonly ILogger logger; protected readonly IApiClient apiClient; + private readonly FileNameGenerator fileNameGenerator; protected string? streamHost; protected bool started = false; @@ -38,12 +40,12 @@ namespace BililiveRecorder.Core.Recording private DateTimeOffset fillerStatsLastTrigger; private TimeSpan durationSinceNoDataReceived; - protected RecordTaskBase(IRoom room, ILogger logger, IApiClient apiClient) + protected RecordTaskBase(IRoom room, ILogger logger, IApiClient apiClient, FileNameGenerator fileNameGenerator) { this.room = room ?? throw new ArgumentNullException(nameof(room)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); - + this.fileNameGenerator = fileNameGenerator ?? throw new ArgumentNullException(nameof(fileNameGenerator)); this.ct = this.cts.Token; this.timer.Elapsed += this.Timer_Elapsed_TriggerNetworkStats; @@ -152,82 +154,15 @@ namespace BililiveRecorder.Core.Recording } } - #region File Name - - protected (string fullPath, string relativePath) CreateFileName() + protected (string fullPath, string relativePath) CreateFileName() => this.fileNameGenerator.CreateFilePath(new FileNameGenerator.FileNameContextData { - var formatString = this.room.RoomConfig.FileNameRecordTemplate!; // TODO - - var now = DateTime.Now; - var date = now.ToString("yyyyMMdd"); - var time = now.ToString("HHmmss"); - var ms = now.ToString("fff"); - var randomStr = this.random.Next(100, 999).ToString(); - - var relativePath = formatString - .Replace(@"{date}", date) - .Replace(@"{time}", time) - .Replace(@"{ms}", ms) - .Replace(@"{random}", randomStr) - .Replace(@"{roomid}", this.room.RoomConfig.RoomId.ToString()) - .Replace(@"{title}", RemoveInvalidFileName(this.room.Title)) - .Replace(@"{name}", RemoveInvalidFileName(this.room.Name)) - .Replace(@"{parea}", RemoveInvalidFileName(this.room.AreaNameParent)) - .Replace(@"{area}", RemoveInvalidFileName(this.room.AreaNameChild)) - ; - - if (!relativePath.EndsWith(".flv", StringComparison.OrdinalIgnoreCase)) - relativePath += ".flv"; - - relativePath = RemoveInvalidFileName(relativePath, ignore_slash: true); - var workDirectory = this.room.RoomConfig.WorkDirectory; - var fullPath = Path.Combine(workDirectory, relativePath); - fullPath = Path.GetFullPath(fullPath); - - if (!CheckIsWithinPath(workDirectory!, Path.GetDirectoryName(fullPath))) - { - this.logger.Warning("录制文件位置超出允许范围,请检查设置。将写入到默认路径。"); - relativePath = Path.Combine(this.room.RoomConfig.RoomId.ToString(), $"{this.room.RoomConfig.RoomId}-{date}-{time}-{randomStr}.flv"); - fullPath = Path.Combine(workDirectory, relativePath); - } - - if (File.Exists(fullPath)) - { - this.logger.Warning("录制文件名冲突,请检查设置。将写入到默认路径。"); - relativePath = Path.Combine(this.room.RoomConfig.RoomId.ToString(), $"{this.room.RoomConfig.RoomId}-{date}-{time}-{randomStr}.flv"); - fullPath = Path.Combine(workDirectory, relativePath); - } - - return (fullPath, relativePath); - } - - internal static string RemoveInvalidFileName(string input, bool ignore_slash = false) - { - foreach (var c in Path.GetInvalidFileNameChars()) - if (!ignore_slash || c != '\\' && c != '/') - input = input.Replace(c, '_'); - return input; - } - - internal static bool CheckIsWithinPath(string parent, string child) - { - if (parent is null || child is null) - return false; - - parent = parent.Replace('/', '\\'); - if (!parent.EndsWith("\\")) - parent += "\\"; - parent = Path.GetFullPath(parent); - - child = child.Replace('/', '\\'); - if (!child.EndsWith("\\")) - child += "\\"; - child = Path.GetFullPath(child); - - return child.StartsWith(parent, StringComparison.Ordinal); - } - - #endregion + Name = this.room.Name, + Title = this.room.Title, + RoomId = this.room.RoomConfig.RoomId, + ShortId = this.room.ShortId, + AreaParent = this.room.AreaNameParent, + AreaChild = this.room.AreaNameChild, + }); #region Api Requests diff --git a/BililiveRecorder.Core/Recording/StandardRecordTask.cs b/BililiveRecorder.Core/Recording/StandardRecordTask.cs index e4f5cef..3e0a026 100644 --- a/BililiveRecorder.Core/Recording/StandardRecordTask.cs +++ b/BililiveRecorder.Core/Recording/StandardRecordTask.cs @@ -9,6 +9,7 @@ using BililiveRecorder.Core.Api; using BililiveRecorder.Core.Config; using BililiveRecorder.Core.Event; using BililiveRecorder.Core.ProcessingRules; +using BililiveRecorder.Core.Templating; using BililiveRecorder.Flv; using BililiveRecorder.Flv.Amf; using BililiveRecorder.Flv.Pipeline; @@ -40,10 +41,12 @@ namespace BililiveRecorder.Core.Recording IApiClient apiClient, IFlvTagReaderFactory flvTagReaderFactory, ITagGroupReaderFactory tagGroupReaderFactory, - IFlvProcessingContextWriterFactory writerFactory) + IFlvProcessingContextWriterFactory writerFactory, + FileNameGenerator fileNameGenerator) : base(room: room, logger: logger?.ForContext().ForContext(LoggingContext.RoomId, room.RoomConfig.RoomId)!, - apiClient: apiClient) + apiClient: apiClient, + fileNameGenerator: fileNameGenerator) { this.flvTagReaderFactory = flvTagReaderFactory ?? throw new ArgumentNullException(nameof(flvTagReaderFactory)); this.tagGroupReaderFactory = tagGroupReaderFactory ?? throw new ArgumentNullException(nameof(tagGroupReaderFactory)); diff --git a/BililiveRecorder.Core/Templating/FileNameGenerator.cs b/BililiveRecorder.Core/Templating/FileNameGenerator.cs new file mode 100644 index 0000000..c2f58bb --- /dev/null +++ b/BililiveRecorder.Core/Templating/FileNameGenerator.cs @@ -0,0 +1,171 @@ +using System; +using System.IO; +using BililiveRecorder.Core.Config.V3; +using Fluid; +using Fluid.Ast; +using Serilog; + +namespace BililiveRecorder.Core.Templating +{ + public class FileNameGenerator + { + private static readonly ILogger logger = Log.Logger.ForContext(); + + private static readonly FluidParser parser = new(); + private static readonly IFluidTemplate defaultTemplate = parser.Parse(DefaultConfig.Instance.FileNameRecordTemplate); + + private static readonly Random _globalRandom = new(); + [ThreadStatic] private static Random? _localRandom; + private static Random Random + { + get + { + if (_localRandom == null) + { + int seed; + lock (_globalRandom) + { + seed = _globalRandom.Next(); + } + _localRandom = new Random(seed); + } + return _localRandom; + } + } + + private readonly GlobalConfig config; + private IFluidTemplate? template; + + static FileNameGenerator() + { + parser.RegisterExpressionTag("random", async static (expression, writer, encoder, context) => + { + var value = await expression.EvaluateAsync(context); + var valueStr = value.ToStringValue(); + if (!int.TryParse(valueStr, out var count)) + count = 3; + + var text = string.Empty; + + while (count > 0) + { + var step = count > 9 ? 9 : count; + count -= step; + var num = Random.Next((int)Math.Pow(10, step)); + text += num.ToString("D" + step); + } + + await writer.WriteAsync(text); + + return Completion.Normal; + }); + parser.Compile(); + } + + public FileNameGenerator(GlobalConfig config) + { + this.config = config ?? throw new ArgumentNullException(nameof(config)); + + config.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(config.FileNameRecordTemplate)) + { + this.UpdateTemplate(); + } + }; + + this.UpdateTemplate(); + } + + private void UpdateTemplate() + { + if (!parser.TryParse(this.config.FileNameRecordTemplate, out var template, out var error)) + { + logger.Warning("文件名模板格式不正确,请修改: {ParserError}", error); + } + this.template = template; + } + + public (string fullPath, string relativePath) CreateFilePath(FileNameContextData data) + { + var now = DateTimeOffset.Now; + var templateOptions = new TemplateOptions + { + Now = () => now, + }; + templateOptions.MemberAccessStrategy.MemberNameStrategy = MemberNameStrategies.CamelCase; + var context = new TemplateContext(data, templateOptions); + + var workDirectory = this.config.WorkDirectory!; + + if (this.template is not { } t) + { + logger.ForContext(LoggingContext.RoomId, data.RoomId).Warning("文件名模板格式不正确,请检查设置。将写入到默认路径。"); + goto returnDefaultPath; + } + + var relativePath = t.Render(context); + relativePath = RemoveInvalidFileName(relativePath); + var fullPath = Path.GetFullPath(Path.Combine(workDirectory, relativePath)); + + if (!CheckIsWithinPath(workDirectory!, Path.GetDirectoryName(fullPath))) + { + logger.ForContext(LoggingContext.RoomId, data.RoomId).Warning("录制文件位置超出允许范围,请检查设置。将写入到默认路径。"); + goto returnDefaultPath; + } + + if (File.Exists(fullPath)) + { + logger.ForContext(LoggingContext.RoomId, data.RoomId).Warning("录制文件名冲突,请检查设置。将写入到默认路径。"); + goto returnDefaultPath; + } + + return (fullPath, relativePath); + + returnDefaultPath: + var defaultRelativePath = RemoveInvalidFileName(defaultTemplate.Render(context)); + return (Path.GetFullPath(Path.Combine(this.config.WorkDirectory, defaultRelativePath)), defaultRelativePath); + } + + public class FileNameContextData + { + public int RoomId { get; set; } + + public int ShortId { get; set; } + + public string Name { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + + public string AreaParent { get; set; } = string.Empty; + + public string AreaChild { get; set; } = string.Empty; + } + + internal static string RemoveInvalidFileName(string input, bool ignore_slash = true) + { + foreach (var c in Path.GetInvalidFileNameChars()) + if (!ignore_slash || c != '\\' && c != '/') + input = input.Replace(c, '_'); + return input; + } + + internal static bool CheckIsWithinPath(string parent, string child) + { + if (parent is null || child is null) + return false; + + parent = parent.Replace('/', '\\'); + if (!parent.EndsWith("\\")) + parent += "\\"; + parent = Path.GetFullPath(parent); + + child = child.Replace('/', '\\'); + if (!child.EndsWith("\\")) + child += "\\"; + child = Path.GetFullPath(child); + + return child.StartsWith(parent, StringComparison.Ordinal); + } + } +} diff --git a/BililiveRecorder.WPF/Pages/SettingsPage.xaml b/BililiveRecorder.WPF/Pages/SettingsPage.xaml index 0bad219..5951194 100644 --- a/BililiveRecorder.WPF/Pages/SettingsPage.xaml +++ b/BililiveRecorder.WPF/Pages/SettingsPage.xaml @@ -87,8 +87,8 @@ - - + + diff --git a/configV3.schema.json b/configV3.schema.json index c43840e..6410674 100644 --- a/configV3.schema.json +++ b/configV3.schema.json @@ -7,8 +7,8 @@ "additionalProperties": false, "properties": { "FileNameRecordTemplate": { - "description": "录制文件名模板\n默认: {roomid}-{name}/录制-{roomid}-{date}-{time}-{ms}-{title}.flv", - "markdownDescription": "录制文件名模板 \n默认: `{roomid}-{name}/录制-{roomid}-{date}-{time}-{ms}-{title}.flv `\n\nTODO: config v3 新的文件名模板系统的文档还没有写", + "description": "录制文件名模板\n默认: \"{{ roomId }}-{{ name }}/录制-{{ roomId }}-{{ \"now\" | format_date: \"yyyyMMdd-HHmmss-fff\" }}-{{ title }}.flv\"", + "markdownDescription": "录制文件名模板 \n默认: `\"{{ roomId }}-{{ name }}/录制-{{ roomId }}-{{ \"now\" | format_date: \"yyyyMMdd-HHmmss-fff\" }}-{{ title }}.flv\" `\n\nTODO: config v3 新的文件名模板系统的文档还没有写", "type": "object", "additionalProperties": false, "properties": { @@ -18,7 +18,7 @@ }, "Value": { "type": "string", - "default": "{roomid}-{name}/录制-{roomid}-{date}-{time}-{ms}-{title}.flv" + "default": "{{ roomId }}-{{ name }}/录制-{{ roomId }}-{{ \"now\" | format_date: \"yyyyMMdd-HHmmss-fff\" }}-{{ title }}.flv" } } }, diff --git a/config_gen/data.ts b/config_gen/data.ts index 955e8cc..1b667ba 100644 --- a/config_gen/data.ts +++ b/config_gen/data.ts @@ -96,7 +96,8 @@ export const data: Array = [ description: "录制文件名模板", type: "string?", configType: "globalOnly", - defaultValue: "@\"{roomid}-{name}/录制-{roomid}-{date}-{time}-{ms}-{title}.flv\"", + defaultValue: "\"{{ roomId }}-{{ name }}/录制-{{ roomId }}-{{ \\\"now\\\" | format_date: \\\"yyyyMMdd-HHmmss-fff\\\" }}-{{ title }}.flv\"", + defaultValueDescription: "\"{{ roomId }}-{{ name }}/录制-{{ roomId }}-{{ \"now\" | format_date: \"yyyyMMdd-HHmmss-fff\" }}-{{ title }}.flv\"", markdown: "TODO: config v3 新的文件名模板系统的文档还没有写" }, { diff --git a/test/BililiveRecorder.Core.UnitTests/Recording/CheckIsWithinPathTests.cs b/test/BililiveRecorder.Core.UnitTests/Recording/CheckIsWithinPathTests.cs index 3797dd4..8c944a0 100644 --- a/test/BililiveRecorder.Core.UnitTests/Recording/CheckIsWithinPathTests.cs +++ b/test/BililiveRecorder.Core.UnitTests/Recording/CheckIsWithinPathTests.cs @@ -32,7 +32,7 @@ namespace BililiveRecorder.Core.UnitTests.Recording public void Test(string parent, string child, bool result) { // TODO fix path tests - Assert.Equal(result, Core.Recording.RecordTaskBase.CheckIsWithinPath(parent, Path.GetDirectoryName(child)!)); + Assert.Equal(result, Core.Templating.FileNameGenerator.CheckIsWithinPath(parent, Path.GetDirectoryName(child)!)); } } }