mirror of
https://github.com/BililiveRecorder/BililiveRecorder.git
synced 2024-11-16 11:42:22 +08:00
Core: Use Fluid.Core to generate file names
This commit is contained in:
parent
441343e92e
commit
20c7a1efb0
|
@ -8,6 +8,7 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Fluid.Core" Version="2.2.8" />
|
||||
<PackageReference Include="JsonSubTypes" Version="1.8.0" />
|
||||
<PackageReference Include="HierarchicalPropertyDefault" Version="0.1.4-beta-g75fdf624b1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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<FileNameGenerator>()
|
||||
.AddScoped<IRecordTaskFactory, RecordTaskFactory>()
|
||||
.AddScoped<IFlvProcessingContextWriterFactory, FlvProcessingContextWriterWithFileWriterFactory>()
|
||||
.AddScoped<IFlvTagReaderFactory, FlvTagReaderFactory>()
|
||||
|
|
|
@ -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<RawDataRecordTask>().ForContext(LoggingContext.RoomId, room.RoomConfig.RoomId)!,
|
||||
apiClient: apiClient)
|
||||
apiClient: apiClient,
|
||||
fileNameGenerator: fileNameGenerator)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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<StandardRecordTask>().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));
|
||||
|
|
171
BililiveRecorder.Core/Templating/FileNameGenerator.cs
Normal file
171
BililiveRecorder.Core/Templating/FileNameGenerator.cs
Normal file
|
@ -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<FileNameGenerator>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -87,8 +87,8 @@
|
|||
<ui:PathIcon Margin="2,0" VerticalAlignment="Center" Height="15" Style="{StaticResource PathIconDataOpenInNew}"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<c:SettingWithDefault IsSettingNotUsingDefault="{Binding HasRecordFilenameFormat}" Header="{l:Loc Settings_FileName_Record}">
|
||||
<TextBox Text="{Binding RecordFilenameFormat,Delay=500}" ui:TextBoxHelper.IsDeleteButtonVisible="False"/>
|
||||
<c:SettingWithDefault IsSettingNotUsingDefault="{Binding HasFileNameRecordTemplate}" Header="{l:Loc Settings_FileName_Record}">
|
||||
<TextBox Text="{Binding FileNameRecordTemplate,Delay=500}" ui:TextBoxHelper.IsDeleteButtonVisible="False"/>
|
||||
</c:SettingWithDefault>
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -96,7 +96,8 @@ export const data: Array<ConfigEntry> = [
|
|||
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 新的文件名模板系统的文档还没有写"
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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)!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user