Core: Refactor file name templateing

This commit is contained in:
genteure 2022-06-28 16:10:06 +08:00
parent bbada97219
commit 0b8cf27ce9
14 changed files with 235 additions and 129 deletions

View File

@ -18,7 +18,7 @@ namespace BililiveRecorder.Core.Config.V3
public bool DisableConfigSave { get; set; } = false; // for CLI
}
public partial class RoomConfig
public partial class RoomConfig : IFileNameConfig
{
public RoomConfig() : base(x => x.AutoMap(p => new[] { "Has" + p.Name }))
{ }
@ -28,7 +28,7 @@ namespace BililiveRecorder.Core.Config.V3
public string? WorkDirectory => this.GetPropertyValue<string>();
}
public partial class GlobalConfig
public partial class GlobalConfig : IFileNameConfig
{
public GlobalConfig() : base(x => x.AutoMap(p => new[] { "Has" + p.Name }))
{

View File

@ -0,0 +1,9 @@
namespace BililiveRecorder.Core.Config.V3
{
public interface IFileNameConfig
{
public string? FileNameRecordTemplate { get; }
public string? WorkDirectory { get; }
}
}

View File

@ -6,7 +6,6 @@ using BililiveRecorder.Core.Config.V3;
using BililiveRecorder.Core.Danmaku;
using BililiveRecorder.Core.Recording;
using BililiveRecorder.Core.Scripting;
using BililiveRecorder.Core.Templating;
using BililiveRecorder.Flv;
using Microsoft.Extensions.DependencyInjection;
using Polly.Registry;
@ -46,7 +45,6 @@ namespace BililiveRecorder.DependencyInjection
;
public static IServiceCollection AddRecorderRecording(this IServiceCollection services) => services
.AddSingleton<FileNameGenerator>()
.AddScoped<IRecordTaskFactory, RecordTaskFactory>()
.AddScoped<IFlvProcessingContextWriterFactory, FlvProcessingContextWriterWithFileWriterFactory>()
.AddScoped<IFlvTagReaderFactory, FlvTagReaderFactory>()

View File

@ -5,7 +5,6 @@ using System.Threading.Tasks;
using BililiveRecorder.Core.Api;
using BililiveRecorder.Core.Event;
using BililiveRecorder.Core.Scripting;
using BililiveRecorder.Core.Templating;
using Serilog;
namespace BililiveRecorder.Core.Recording
@ -17,12 +16,10 @@ namespace BililiveRecorder.Core.Recording
public RawDataRecordTask(IRoom room,
ILogger logger,
IApiClient apiClient,
FileNameGenerator fileNameGenerator,
UserScriptRunner userScriptRunner)
: base(room: room,
logger: logger?.ForContext<RawDataRecordTask>().ForContext(LoggingContext.RoomId, room.RoomConfig.RoomId)!,
apiClient: apiClient,
fileNameGenerator: fileNameGenerator,
userScriptRunner: userScriptRunner)
{
}

View File

@ -52,13 +52,14 @@ namespace BililiveRecorder.Core.Recording
private DateTimeOffset ioStatsLastTrigger;
private TimeSpan durationSinceNoDataReceived;
protected RecordTaskBase(IRoom room, ILogger logger, IApiClient apiClient, FileNameGenerator fileNameGenerator, UserScriptRunner userScriptRunner)
protected RecordTaskBase(IRoom room, ILogger logger, IApiClient apiClient, UserScriptRunner userScriptRunner)
{
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.userScriptRunner = userScriptRunner ?? throw new ArgumentNullException(nameof(userScriptRunner));
this.fileNameGenerator = new FileNameGenerator(room.RoomConfig, logger);
this.ct = this.cts.Token;
this.timer.Elapsed += this.Timer_Elapsed_TriggerIOStats;
@ -174,17 +175,22 @@ namespace BililiveRecorder.Core.Recording
}
}
protected (string fullPath, string relativePath) CreateFileName() => this.fileNameGenerator.CreateFilePath(new FileNameTemplateContext
protected (string fullPath, string relativePath) CreateFileName()
{
Name = FileNameGenerator.RemoveInvalidFileName(this.room.Name, ignore_slash: false),
Title = FileNameGenerator.RemoveInvalidFileName(this.room.Title, ignore_slash: false),
RoomId = this.room.RoomConfig.RoomId,
ShortId = this.room.ShortId,
AreaParent = FileNameGenerator.RemoveInvalidFileName(this.room.AreaNameParent, ignore_slash: false),
AreaChild = FileNameGenerator.RemoveInvalidFileName(this.room.AreaNameChild, ignore_slash: false),
Qn = this.qn,
Json = this.room.RawBilibiliApiJsonData,
});
var output = this.fileNameGenerator.CreateFilePath(new FileNameTemplateContext
{
Name = FileNameGenerator.RemoveInvalidFileName(this.room.Name, ignore_slash: false),
Title = FileNameGenerator.RemoveInvalidFileName(this.room.Title, ignore_slash: false),
RoomId = this.room.RoomConfig.RoomId,
ShortId = this.room.ShortId,
AreaParent = FileNameGenerator.RemoveInvalidFileName(this.room.AreaNameParent, ignore_slash: false),
AreaChild = FileNameGenerator.RemoveInvalidFileName(this.room.AreaNameChild, ignore_slash: false),
Qn = this.qn,
Json = this.room.RawBilibiliApiJsonData,
});
return (output.FullPath!, output.RelativePath);
}
#region Api Requests

View File

@ -10,7 +10,6 @@ using BililiveRecorder.Core.Config;
using BililiveRecorder.Core.Event;
using BililiveRecorder.Core.ProcessingRules;
using BililiveRecorder.Core.Scripting;
using BililiveRecorder.Core.Templating;
using BililiveRecorder.Flv;
using BililiveRecorder.Flv.Amf;
using BililiveRecorder.Flv.Pipeline;
@ -44,12 +43,10 @@ namespace BililiveRecorder.Core.Recording
IFlvTagReaderFactory flvTagReaderFactory,
ITagGroupReaderFactory tagGroupReaderFactory,
IFlvProcessingContextWriterFactory writerFactory,
FileNameGenerator fileNameGenerator,
UserScriptRunner userScriptRunner)
: base(room: room,
logger: logger?.ForContext<StandardRecordTask>().ForContext(LoggingContext.RoomId, room.RoomConfig.RoomId)!,
apiClient: apiClient,
fileNameGenerator: fileNameGenerator,
userScriptRunner: userScriptRunner)
{
this.flvTagReaderFactory = flvTagReaderFactory ?? throw new ArgumentNullException(nameof(flvTagReaderFactory));

View File

@ -7,6 +7,7 @@ using Fluid.Ast;
using Fluid.Values;
using Newtonsoft.Json.Linq;
using Serilog;
using Serilog.Core;
namespace BililiveRecorder.Core.Templating
{
@ -15,8 +16,6 @@ namespace BililiveRecorder.Core.Templating
// TODO: 需要改得更通用一些
// 日志不应该一定绑定到一个直播间上
private static readonly ILogger logger = Log.Logger.ForContext<FileNameGenerator>();
private static readonly FluidParser parser;
private static readonly IFluidTemplate defaultTemplate;
@ -39,8 +38,8 @@ namespace BililiveRecorder.Core.Templating
}
}
private readonly GlobalConfig config;
private IFluidTemplate? template;
private readonly IFileNameConfig config;
private readonly ILogger logger;
static FileNameGenerator()
{
@ -72,32 +71,22 @@ namespace BililiveRecorder.Core.Templating
defaultTemplate = parser.Parse(DefaultConfig.Instance.FileNameRecordTemplate);
}
public FileNameGenerator(GlobalConfig config)
public FileNameGenerator(IFileNameConfig config, ILogger? logger)
{
this.config = config ?? throw new ArgumentNullException(nameof(config));
config.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(config.FileNameRecordTemplate))
{
this.UpdateTemplate();
}
};
this.UpdateTemplate();
this.logger = logger?.ForContext<FileNameGenerator>() ?? Logger.None;
}
private void UpdateTemplate()
public FileNameTemplateOutput CreateFilePath(FileNameTemplateContext data)
{
if (!parser.TryParse(this.config.FileNameRecordTemplate, out var template, out var error))
{
logger.Warning("文件名模板格式不正确,请修改: {ParserError}", error);
}
this.template = template;
}
var status = FileNameTemplateStatus.Success;
string? errorMessage = null;
string relativePath;
string? fullPath;
var workDirectory = this.config.WorkDirectory;
var skipFullPath = workDirectory is null;
public (string fullPath, string relativePath) CreateFilePath(FileNameTemplateContext data)
{
var now = DateTimeOffset.Now;
var templateOptions = new TemplateOptions
{
@ -105,49 +94,57 @@ namespace BililiveRecorder.Core.Templating
};
templateOptions.MemberAccessStrategy.MemberNameStrategy = MemberNameStrategies.CamelCase;
templateOptions.ValueConverters.Add(o => o is JContainer j ? new JContainerValue(j) : null);
templateOptions.Filters.AddFilter("format_qn", static (FluidValue input, FilterArguments arguments, TemplateContext context)
=> new StringValue(StreamQualityNumber.MapToString((int)input.ToNumberValue()))
);
templateOptions.Filters.AddFilter("format_qn",
static (FluidValue input, FilterArguments arguments, TemplateContext context) => new StringValue(StreamQualityNumber.MapToString((int)input.ToNumberValue())));
var context = new TemplateContext(data, templateOptions);
var workDirectory = this.config.WorkDirectory!;
if (this.template is not { } t)
if (!parser.TryParse(this.config.FileNameRecordTemplate, out var template, out var error))
{
logger.ForContext(LoggingContext.RoomId, data.RoomId).Warning("文件名模板格式不正确,请检查设置。将写入到默认路径。");
this.logger.Warning("文件名模板格式不正确,请修改: {ParserError}", error);
errorMessage = "文件名模板格式不正确,请修改: " + error;
status = FileNameTemplateStatus.TemplateError;
goto returnDefaultPath;
}
var relativePath = t.Render(context);
relativePath = template.Render(context);
relativePath = RemoveInvalidFileName(relativePath);
var fullPath = Path.GetFullPath(Path.Combine(workDirectory, relativePath));
if (!CheckIsWithinPath(workDirectory!, Path.GetDirectoryName(fullPath)))
fullPath = skipFullPath ? null : Path.GetFullPath(Path.Combine(workDirectory, relativePath));
if (!skipFullPath && !CheckIsWithinPath(workDirectory!, Path.GetDirectoryName(fullPath)))
{
logger.ForContext(LoggingContext.RoomId, data.RoomId).Warning("录制文件位置超出允许范围,请检查设置。将写入到默认路径。");
this.logger.Warning("录制文件位置超出允许范围,请检查设置。将写入到默认路径。");
status = FileNameTemplateStatus.OutOfRange;
errorMessage = "录制文件位置超出允许范围";
goto returnDefaultPath;
}
var ext = Path.GetExtension(fullPath);
var ext = Path.GetExtension(relativePath);
if (!ext.Equals(".flv", StringComparison.OrdinalIgnoreCase))
{
logger.ForContext(LoggingContext.RoomId, data.RoomId).Warning("录播姬只支持 FLV 文件格式,将在录制文件后缀名 {ExtensionName} 后添加 .flv。", ext);
this.logger.Warning("录播姬只支持 FLV 文件格式,将在录制文件后缀名 {ExtensionName} 后添加 {DotFlv}。", ext, ".flv");
relativePath += ".flv";
fullPath += ".flv";
if (!skipFullPath)
fullPath += ".flv";
}
if (File.Exists(fullPath))
if (!skipFullPath && File.Exists(fullPath))
{
logger.ForContext(LoggingContext.RoomId, data.RoomId).Warning("录制文件名冲突,请检查设置。将写入到默认路径。");
this.logger.Warning("录制文件名冲突,将写入到默认路径。");
status = FileNameTemplateStatus.FileConflict;
errorMessage = "录制文件名冲突";
goto returnDefaultPath;
}
return (fullPath, relativePath);
return new FileNameTemplateOutput(status, errorMessage, relativePath, fullPath);
returnDefaultPath:
var defaultRelativePath = RemoveInvalidFileName(defaultTemplate.Render(context));
return (Path.GetFullPath(Path.Combine(this.config.WorkDirectory, defaultRelativePath)), defaultRelativePath);
var defaultFullPath = skipFullPath ? null : Path.GetFullPath(Path.Combine(workDirectory, defaultRelativePath));
return new FileNameTemplateOutput(status, errorMessage, defaultRelativePath, defaultFullPath);
}
private class JContainerValue : ObjectValueBase
@ -238,22 +235,29 @@ namespace BililiveRecorder.Core.Templating
return input;
}
private static readonly char[] separator = new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar };
internal static bool CheckIsWithinPath(string parent, string child)
{
if (parent is null || child is null)
var fullParent = Path.GetFullPath(parent);
var fullChild = Path.GetFullPath(child);
var parentSegments = fullParent.Split(separator, StringSplitOptions.None).AsSpan();
if (parentSegments[parentSegments.Length - 1] == "")
{
parentSegments = parentSegments.Slice(0, parentSegments.Length - 1);
}
var childSegments = fullChild.Split(separator, StringSplitOptions.None).AsSpan();
if (childSegments[childSegments.Length - 1] == "")
{
childSegments = childSegments.Slice(0, childSegments.Length - 1);
}
if (parentSegments.Length >= childSegments.Length)
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);
return childSegments.Slice(0, parentSegments.Length).SequenceEqual(parentSegments);
}
}
}

View File

@ -0,0 +1,23 @@
using System;
namespace BililiveRecorder.Core.Templating
{
public readonly struct FileNameTemplateOutput
{
public FileNameTemplateOutput(FileNameTemplateStatus status, string? errorMessage, string relativePath, string? fullPath)
{
this.Status = status;
this.ErrorMessage = errorMessage;
this.RelativePath = relativePath ?? throw new ArgumentNullException(nameof(relativePath));
this.FullPath = fullPath;
}
public FileNameTemplateStatus Status { get; }
public string? ErrorMessage { get; }
public string RelativePath { get; }
public string? FullPath { get; }
}
}

View File

@ -0,0 +1,10 @@
namespace BililiveRecorder.Core.Templating
{
public enum FileNameTemplateStatus
{
Success = 0,
TemplateError,
OutOfRange,
FileConflict,
}
}

View File

@ -11,6 +11,7 @@
l:ResxLocalizationProvider.DefaultDictionary="Strings"
xmlns:c="clr-namespace:BililiveRecorder.WPF.Controls"
xmlns:m="clr-namespace:BililiveRecorder.WPF.Models"
xmlns:t="clr-namespace:BililiveRecorder.Core.Templating;assembly=BililiveRecorder.Core"
xmlns:local="clr-namespace:BililiveRecorder.WPF.Pages"
xmlns:config="clr-namespace:BililiveRecorder.Core.Config;assembly=BililiveRecorder.Core"
xmlns:configv3="clr-namespace:BililiveRecorder.Core.Config.V3;assembly=BililiveRecorder.Core"
@ -96,7 +97,14 @@
<TextBox Text="{Binding FileNameRecordTemplate,Delay=500}" ui:TextBoxHelper.IsDeleteButtonVisible="False"/>
</c:SettingWithDefault>
<Button Margin="0,10,0,5" Content="测试" Click="TestFileNameTemplate_Button_Click"/>
<TextBox Visibility="Collapsed" IsReadOnly="True" x:Name="FileNameTestResult"/>
<Border x:Name="FileNameTestResultArea" d:DataContext="{d:DesignInstance Type=t:FileNameTemplateOutput}" Visibility="Collapsed"
BorderBrush="{DynamicResource SystemControlBackgroundBaseMediumBrush}" CornerRadius="5" BorderThickness="1" Padding="5">
<StackPanel Orientation="Vertical" Margin="0">
<TextBlock Text="测试结果:"/>
<TextBlock Text="{Binding ErrorMessage,Mode=OneWay}" Margin="0,5"/>
<TextBox IsReadOnly="True" Text="{Binding RelativePath,Mode=OneWay}"/>
</StackPanel>
</Border>
</StackPanel>
</GroupBox>
<GroupBox Header="录制画质">

File diff suppressed because one or more lines are too long

View File

@ -23,19 +23,19 @@ namespace BililiveRecorder.Web.Api
/// <param name="input"></param>
/// <returns></returns>
[HttpPost("generateFileName")]
public ActionResult<string> GenerateFileName([FromBody] GenerateFileNameInput input)
public ActionResult<FileNameTemplateOutput> GenerateFileName([FromBody] GenerateFileNameInput input)
{
var config = new GlobalConfig()
{
WorkDirectory = "/",
FileNameRecordTemplate = input.Template
};
var generator = new FileNameGenerator(config);
var generator = new FileNameGenerator(config, null);
var context = this.mapper.Map<FileNameTemplateContext>(input.Context);
var (_, relativePath) = generator.CreateFilePath(context);
return relativePath;
var output = generator.CreateFilePath(context);
return output;
}
}
}

View File

@ -92,7 +92,7 @@ namespace BililiveRecorder.Core.Config.V3
public bool WpfShowTitleAndArea { get; }
}
[Newtonsoft.Json.JsonObject(Newtonsoft.Json.MemberSerialization.OptIn)]
public sealed class GlobalConfig : HierarchicalPropertyDefault.HierarchicalObject<BililiveRecorder.Core.Config.V3.DefaultConfig, BililiveRecorder.Core.Config.V3.GlobalConfig>
public sealed class GlobalConfig : HierarchicalPropertyDefault.HierarchicalObject<BililiveRecorder.Core.Config.V3.DefaultConfig, BililiveRecorder.Core.Config.V3.GlobalConfig>, BililiveRecorder.Core.Config.V3.IFileNameConfig
{
public GlobalConfig() { }
public string? Cookie { get; set; }
@ -201,8 +201,13 @@ namespace BililiveRecorder.Core.Config.V3
public string? WorkDirectory { get; set; }
public bool WpfShowTitleAndArea { get; set; }
}
public interface IFileNameConfig
{
string? FileNameRecordTemplate { get; }
string? WorkDirectory { get; }
}
[Newtonsoft.Json.JsonObject(Newtonsoft.Json.MemberSerialization.OptIn)]
public sealed class RoomConfig : HierarchicalPropertyDefault.HierarchicalObject<BililiveRecorder.Core.Config.V3.GlobalConfig, BililiveRecorder.Core.Config.V3.RoomConfig>
public sealed class RoomConfig : HierarchicalPropertyDefault.HierarchicalObject<BililiveRecorder.Core.Config.V3.GlobalConfig, BililiveRecorder.Core.Config.V3.RoomConfig>, BililiveRecorder.Core.Config.V3.IFileNameConfig
{
public RoomConfig() { }
public bool AutoRecord { get; set; }
@ -474,11 +479,8 @@ namespace BililiveRecorder.Core.Templating
{
public sealed class FileNameGenerator
{
public FileNameGenerator(BililiveRecorder.Core.Config.V3.GlobalConfig config) { }
[return: System.Runtime.CompilerServices.TupleElementNames(new string[] {
"fullPath",
"relativePath"})]
public System.ValueTuple<string, string> CreateFilePath(BililiveRecorder.Core.Templating.FileNameTemplateContext data) { }
public FileNameGenerator(BililiveRecorder.Core.Config.V3.IFileNameConfig config, Serilog.ILogger? logger) { }
public BililiveRecorder.Core.Templating.FileNameTemplateOutput CreateFilePath(BililiveRecorder.Core.Templating.FileNameTemplateContext data) { }
}
public class FileNameTemplateContext
{
@ -492,6 +494,21 @@ namespace BililiveRecorder.Core.Templating
public int ShortId { get; set; }
public string Title { get; set; }
}
public readonly struct FileNameTemplateOutput
{
public FileNameTemplateOutput(BililiveRecorder.Core.Templating.FileNameTemplateStatus status, string? errorMessage, string relativePath, string? fullPath) { }
public string? ErrorMessage { get; }
public string? FullPath { get; }
public string RelativePath { get; }
public BililiveRecorder.Core.Templating.FileNameTemplateStatus Status { get; }
}
public enum FileNameTemplateStatus
{
Success = 0,
TemplateError = 1,
OutOfRange = 2,
FileConflict = 3,
}
}
namespace BililiveRecorder.DependencyInjection
{
@ -502,4 +519,4 @@ namespace BililiveRecorder.DependencyInjection
public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddRecorderConfig(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, BililiveRecorder.Core.Config.V3.ConfigV3 config) { }
public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddRecorderRecording(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) { }
}
}
}

View File

@ -1,38 +1,70 @@
using System.Collections.Generic;
using System.IO;
using BililiveRecorder.Core.Templating;
using Xunit;
namespace BililiveRecorder.Core.UnitTests.Recording
{
public class CheckIsWithinPathTests
{
[Theory(Skip = "Path 差异")]
[InlineData(@"C:\", @"C:\", false)]
[InlineData(@"C:", @"C:\foo", true)]
[InlineData(@"C:\", @"C:\foo", true)]
[InlineData(@"C:\foo", @"C:\foo", false)]
[InlineData(@"C:\foo\", @"C:\foo", false)]
[InlineData(@"C:\foo", @"C:\foo\", true)]
[InlineData(@"C:\foo\", @"C:\foo\bar\", true)]
[InlineData(@"C:\foo\", @"C:\foo\bar", true)]
[InlineData(@"C:\foo", @"C:\FOO\bar", false)]
[InlineData(@"C:\foo", @"C:/foo/bar", true)]
[InlineData(@"C:\foo", @"C:\foobar", false)]
[InlineData(@"C:\foo", @"C:\foobar\baz", false)]
[InlineData(@"C:\foo\", @"C:\foobar\baz", false)]
[InlineData(@"C:\foobar", @"C:\foo\bar", false)]
[InlineData(@"C:\foobar\", @"C:\foo\bar", false)]
[InlineData(@"C:\foo", @"C:\foo\..\bar\baz", false)]
[InlineData(@"C:\bar", @"C:\foo\..\bar\baz", true)]
[InlineData(@"C:\barr", @"C:\foo\..\bar\baz", false)]
[InlineData(@"C:\foo\", @"D:\foo\bar", false)]
[InlineData(@"\\server1\vol1\foo", @"\\server1\vol1\foo", false)]
[InlineData(@"\\server1\vol1\foo", @"\\server1\vol1\bar", false)]
[InlineData(@"\\server1\vol1\foo", @"\\server1\vol1\foo\bar", true)]
[InlineData(@"\\server1\vol1\foo", @"\\server1\vol1\foo\..\bar", false)]
public void Test(string parent, string child, bool result)
[Theory, MemberData(nameof(GetTestData))]
public void RunTest(bool expectation, string parent, string child)
{
// TODO fix path tests
Assert.Equal(result, Core.Templating.FileNameGenerator.CheckIsWithinPath(parent, Path.GetDirectoryName(child)!));
Assert.Equal(expectation, FileNameGenerator.CheckIsWithinPath(parent, child));
}
public static IEnumerable<object[]> GetTestData()
{
yield return new object[] { true, @"/path/a/", "/path/a/file.flv" };
yield return new object[] { true, @"/path/a", "/path/a/file.flv" };
yield return new object[] { true, @"/path/a/", "/path/a/b/file.flv" };
yield return new object[] { true, @"/path/a", "/path/a/b/file.flv" };
yield return new object[] { true, @"/path/a/", "/path/a/../a/file.flv" };
yield return new object[] { true, @"/path/a", "/path/a/../a/file.flv" };
yield return new object[] { false, @"/path/a/", "/path/a/../b/file.flv" };
yield return new object[] { false, @"/path/a", "/path/a/../b/file.flv" };
yield return new object[] { true, @"/", "/path/a/file.flv" };
yield return new object[] { true, @"/", "/file.flv" };
yield return new object[] { false, @"/path", "/path/a/../../../../file.flv" };
yield return new object[] { false, @"/path", "/path../../../../file.flv" };
yield return new object[] { true, @"/path/a/", "/path////a/file.flv" };
yield return new object[] { true, @"/path/a", "/path////a/file.flv" };
yield return new object[] { false, @"/path/", "/path/" };
yield return new object[] { false, @"/path", "/path" };
yield return new object[] { false, @"/path/", "/path" };
yield return new object[] { false, @"/path", "/path/" };
var isWindows = Path.DirectorySeparatorChar == '\\';
yield return new object[] { isWindows && false, @"C:\", @"C:\" };
yield return new object[] { isWindows && true, @"C:\", @"C:\foo" };
yield return new object[] { isWindows && false, @"C:\foo", @"C:\foo" };
yield return new object[] { isWindows && false, @"C:\foo\", @"C:\foo" };
yield return new object[] { isWindows && false, @"C:\foo", @"C:\foo\" };
yield return new object[] { isWindows && true, @"C:\foo\", @"C:\foo\bar\" };
yield return new object[] { isWindows && true, @"C:\foo\", @"C:\foo\bar" };
yield return new object[] { isWindows && false, @"C:\foo", @"C:\FOO\bar" };
yield return new object[] { isWindows && true, @"C:\foo", @"C:/foo/bar" };
yield return new object[] { isWindows && false, @"C:\foo", @"C:\foobar" };
yield return new object[] { isWindows && false, @"C:\foo", @"C:\foobar\baz" };
yield return new object[] { isWindows && false, @"C:\foo\", @"C:\foobar\baz" };
yield return new object[] { isWindows && false, @"C:\foobar", @"C:\foo\bar" };
yield return new object[] { isWindows && false, @"C:\foobar\", @"C:\foo\bar" };
yield return new object[] { isWindows && false, @"C:\foo", @"C:\foo\..\bar\baz" };
yield return new object[] { isWindows && true, @"C:\bar", @"C:\foo\..\bar\baz" };
yield return new object[] { isWindows && false, @"C:\barr", @"C:\foo\..\bar\baz" };
yield return new object[] { isWindows && false, @"C:\foo\", @"D:\foo\bar" };
yield return new object[] { isWindows && false, @"\\server1\vol1\foo", @"\\server1\vol1\foo" };
yield return new object[] { isWindows && false, @"\\server1\vol1\foo", @"\\server1\vol1\bar" };
yield return new object[] { isWindows && true, @"\\server1\vol1\foo", @"\\server1\vol1\foo\bar" };
yield return new object[] { isWindows && false, @"\\server1\vol1\foo", @"\\server1\vol1\foo\..\bar" };
}
}
}