implemented logger and M3U8 parser
This commit is contained in:
parent
952e4f290c
commit
9f04c3e9b0
3
.gitignore
vendored
3
.gitignore
vendored
@ -2,4 +2,5 @@ bin/
|
|||||||
obj/
|
obj/
|
||||||
/packages/
|
/packages/
|
||||||
riderModule.iml
|
riderModule.iml
|
||||||
/_ReSharper.Caches/
|
/_ReSharper.Caches/
|
||||||
|
.idea/
|
||||||
13
.idea/.idea.VkAudioDownloader/.idea/.gitignore
vendored
13
.idea/.idea.VkAudioDownloader/.idea/.gitignore
vendored
@ -1,13 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# Rider ignored files
|
|
||||||
/contentModel.xml
|
|
||||||
/.idea.VkAudioDownloader.iml
|
|
||||||
/modules.xml
|
|
||||||
/projectSettingsUpdater.xml
|
|
||||||
# Editor-based HTTP Client requests
|
|
||||||
/httpRequests/
|
|
||||||
# Datasource local storage ignored files
|
|
||||||
/dataSources/
|
|
||||||
/dataSources.local.xml
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Encoding">
|
|
||||||
<file url="PROJECT" charset="UTF-8" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="UserContentModel">
|
|
||||||
<attachedFolders />
|
|
||||||
<explicitIncludes />
|
|
||||||
<explicitExcludes />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="com.jetbrains.rider.android.RiderAndroidMiscFileCreationComponent">
|
|
||||||
<option name="ENSURE_MISC_FILE_EXISTS" value="true" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
|
||||||
<mapping directory="$PROJECT_DIR$/DTLib" vcs="Git" />
|
|
||||||
<mapping directory="$PROJECT_DIR$/VkNet.AudioBypass" vcs="Git" />
|
|
||||||
<mapping directory="$PROJECT_DIR$/vknet" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
2
DTLib
2
DTLib
@ -1 +1 @@
|
|||||||
Subproject commit f6d045ae2d97691d67bfa82a26e9c100bb3215a1
|
Subproject commit c7016371a53efa266453e0ab4aacaae4fe260e9f
|
||||||
@ -1,11 +1,30 @@
|
|||||||
using DTLib.Dtsod;
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using DTLib.Dtsod;
|
||||||
|
using DTLib.Filesystem;
|
||||||
using VkAudioDownloader;
|
using VkAudioDownloader;
|
||||||
|
using DTLib.Logging.New;
|
||||||
|
using VkAudioDownloader.VkM3U8;
|
||||||
|
|
||||||
var client = new VkClient(VkClientConfig.FromDtsod(new DtsodV23(File.ReadAllText("config.dtsod"))));
|
|
||||||
|
if(!File.Exists("config.dtsod"))
|
||||||
|
File.Copy("config.dtsod.default","config.dtsod");
|
||||||
|
|
||||||
|
var logger = new CompositeLogger(new DefaultLogFormat(true),
|
||||||
|
new ConsoleLogger(),
|
||||||
|
new FileLogger("logs", "VkAudioDownloaer"));
|
||||||
|
var client = new VkClient(
|
||||||
|
VkClientConfig.FromDtsod(new DtsodV23(File.ReadAllText("config.dtsod"))),
|
||||||
|
logger);
|
||||||
|
logger.Log("main", LogSeverity.Info, "initializing api...");
|
||||||
|
logger.DebugLogEnabled = true;
|
||||||
client.Connect();
|
client.Connect();
|
||||||
Console.WriteLine(client.Api.Token);
|
var audio = client.FindAudio("гражданская оборона", 1).First();
|
||||||
var audios = client.FindAudio("моя оборона");
|
Console.WriteLine($"{audio.Title} -- {audio.Artist} [{TimeSpan.FromSeconds(audio.Duration)}]");
|
||||||
foreach (var a in audios)
|
var Http = new HttpClient();
|
||||||
{
|
var m3u8 = await Http.GetStringAsync(audio.Url);
|
||||||
Console.WriteLine(a.Title);
|
Console.WriteLine("downloaded m3u8 playlist:\n" + m3u8);
|
||||||
}
|
var parser = new M3U8Parser();
|
||||||
|
var HLSPlaylist = parser.Parse(audio.Url, m3u8);
|
||||||
|
Console.WriteLine(HLSPlaylist);
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
<LangVersion>10</LangVersion>
|
<LangVersion>10</LangVersion>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>disable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VkNet.Generators", "vknet\V
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VkNet.AudioBypassService", "VkNet.AudioBypass\VkNet.AudioBypassService\VkNet.AudioBypassService.csproj", "{5650A6DD-602D-49FF-A0B0-DB59BD63EACE}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VkNet.AudioBypassService", "VkNet.AudioBypass\VkNet.AudioBypassService\VkNet.AudioBypassService.csproj", "{5650A6DD-602D-49FF-A0B0-DB59BD63EACE}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sulution_items", "sulution_items", "{2CBCAE99-53A3-4ADE-A08B-5755EC471878}"
|
||||||
|
ProjectSection(SolutionItems) = preProject
|
||||||
|
.gitignore = .gitignore
|
||||||
|
.gitmodules = .gitmodules
|
||||||
|
EndProjectSection
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DTLib.Logging", "DTLib\DTLib.Logging\DTLib.Logging.csproj", "{A087B535-371A-4A7E-883E-B5B290567E9A}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@ -48,5 +56,9 @@ Global
|
|||||||
{5650A6DD-602D-49FF-A0B0-DB59BD63EACE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{5650A6DD-602D-49FF-A0B0-DB59BD63EACE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{5650A6DD-602D-49FF-A0B0-DB59BD63EACE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{5650A6DD-602D-49FF-A0B0-DB59BD63EACE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{5650A6DD-602D-49FF-A0B0-DB59BD63EACE}.Release|Any CPU.Build.0 = Release|Any CPU
|
{5650A6DD-602D-49FF-A0B0-DB59BD63EACE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{A087B535-371A-4A7E-883E-B5B290567E9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A087B535-371A-4A7E-883E-B5B290567E9A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A087B535-371A-4A7E-883E-B5B290567E9A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A087B535-371A-4A7E-883E-B5B290567E9A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@ -3,12 +3,14 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
<LangVersion>10</LangVersion>
|
<LangVersion>10</LangVersion>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>disable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DTLib\DTLib.Dtsod\DTLib.Dtsod.csproj" />
|
<ProjectReference Include="..\DTLib\DTLib.Dtsod\DTLib.Dtsod.csproj" />
|
||||||
|
<ProjectReference Include="..\DTLib\DTLib.Logging\DTLib.Logging.csproj" />
|
||||||
<ProjectReference Include="..\DTLib\DTLib\DTLib.csproj" />
|
<ProjectReference Include="..\DTLib\DTLib\DTLib.csproj" />
|
||||||
<ProjectReference Include="..\VkNet.AudioBypass\VkNet.AudioBypassService\VkNet.AudioBypassService.csproj" />
|
<ProjectReference Include="..\VkNet.AudioBypass\VkNet.AudioBypassService\VkNet.AudioBypassService.csproj" />
|
||||||
<ProjectReference Include="..\vknet\VkNet.Generators\VkNet.Generators.csproj" />
|
<ProjectReference Include="..\vknet\VkNet.Generators\VkNet.Generators.csproj" />
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
global using DTLib;
|
using System;
|
||||||
global using DTLib.Extensions;
|
using System.IO;
|
||||||
using DTLib.Logging;
|
using System.Net.Http;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using VkNet;
|
using VkNet;
|
||||||
@ -9,8 +9,10 @@ using VkNet.Model;
|
|||||||
using VkNet.Model.RequestParams;
|
using VkNet.Model.RequestParams;
|
||||||
using VkNet.Utils;
|
using VkNet.Utils;
|
||||||
using VkNet.Model.Attachments;
|
using VkNet.Model.Attachments;
|
||||||
using VkNet.AudioBypassService;
|
|
||||||
using VkNet.AudioBypassService.Extensions;
|
using VkNet.AudioBypassService.Extensions;
|
||||||
|
using DTLib.Logging.DependencyInjection;
|
||||||
|
using DTLib.Logging.New;
|
||||||
|
using ILogger = DTLib.Logging.New.ILogger;
|
||||||
|
|
||||||
namespace VkAudioDownloader;
|
namespace VkAudioDownloader;
|
||||||
|
|
||||||
@ -20,15 +22,16 @@ public class VkClient : IDisposable
|
|||||||
{
|
{
|
||||||
public VkApi Api;
|
public VkApi Api;
|
||||||
public VkClientConfig Config;
|
public VkClientConfig Config;
|
||||||
|
private ILogger _logger;
|
||||||
public VkClient(VkClientConfig conf)
|
|
||||||
|
public VkClient(VkClientConfig conf, ILogger logger)
|
||||||
{
|
{
|
||||||
Config = conf;
|
Config = conf;
|
||||||
var services = new ServiceCollection();
|
_logger = logger;
|
||||||
//services.AddSingleton<LoggerService>();
|
var services = new ServiceCollection()
|
||||||
services.AddAudioBypass();
|
.Add(new LoggerService<VkApi>(logger))
|
||||||
|
.AddAudioBypass();
|
||||||
Api = new VkApi(services);
|
Api = new VkApi(services);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Connect()
|
public void Connect()
|
||||||
@ -40,12 +43,12 @@ public class VkClient : IDisposable
|
|||||||
};
|
};
|
||||||
if (Config.Token is not null)
|
if (Config.Token is not null)
|
||||||
{
|
{
|
||||||
Console.WriteLine("authorizing by token");
|
_logger.Log(nameof(VkClient),LogSeverity.Info,"authorizing by token");
|
||||||
authParams.AccessToken = Config.Token;
|
authParams.AccessToken = Config.Token;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Console.WriteLine("authorizing by login and password");
|
_logger.Log(nameof(VkClient),LogSeverity.Info,"authorizing by login and password");
|
||||||
authParams.Login = Config.Login;
|
authParams.Login = Config.Login;
|
||||||
authParams.Password = Config.Password;
|
authParams.Password = Config.Password;
|
||||||
}
|
}
|
||||||
|
|||||||
35
VkAudioDownloader/VkM3U8/AudioDecryptor.cs
Normal file
35
VkAudioDownloader/VkM3U8/AudioDecryptor.cs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace VkAudioDownloader.VkM3U8;
|
||||||
|
|
||||||
|
public class AudioDecryptor : IDisposable
|
||||||
|
{
|
||||||
|
private Aes Aes;
|
||||||
|
private ICryptoTransform Decryptor;
|
||||||
|
|
||||||
|
AudioDecryptor()
|
||||||
|
{
|
||||||
|
Aes=Aes.Create();
|
||||||
|
Aes.KeySize = 128;
|
||||||
|
Aes.Mode = CipherMode.CBC;
|
||||||
|
Aes.IV = new byte[4] { 0, 0, 0, 0 };
|
||||||
|
Aes.Padding = PaddingMode.Zeros;
|
||||||
|
Decryptor = Aes.CreateDecryptor();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Stream Decrypt(Stream fragment)
|
||||||
|
=> new CryptoStream(fragment, Decryptor, CryptoStreamMode.Read);
|
||||||
|
|
||||||
|
private bool _disposed;
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if(_disposed) return;
|
||||||
|
Aes.Dispose();
|
||||||
|
Decryptor.Dispose();
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
~AudioDecryptor() => Dispose();
|
||||||
|
}
|
||||||
9
VkAudioDownloader/VkM3U8/HLSFragment.cs
Normal file
9
VkAudioDownloader/VkM3U8/HLSFragment.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
namespace VkAudioDownloader.VkM3U8;
|
||||||
|
|
||||||
|
public record struct HLSFragment
|
||||||
|
{
|
||||||
|
public string Name;
|
||||||
|
// public int Duration;
|
||||||
|
public bool Encrypted;
|
||||||
|
public string? EncryptionKeyUrl;
|
||||||
|
}
|
||||||
25
VkAudioDownloader/VkM3U8/HLSPlaylist.cs
Normal file
25
VkAudioDownloader/VkM3U8/HLSPlaylist.cs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
namespace VkAudioDownloader.VkM3U8;
|
||||||
|
|
||||||
|
public class HLSPlaylist
|
||||||
|
{
|
||||||
|
|
||||||
|
public HLSFragment[] Fragments { get; internal set; }
|
||||||
|
|
||||||
|
/// content duration in seconds
|
||||||
|
public int Duration { get; internal set; }
|
||||||
|
|
||||||
|
/// url before index.m3u8
|
||||||
|
public string BaseUrl { get; internal set; }
|
||||||
|
|
||||||
|
internal HLSPlaylist(HLSFragment[] fragments, int duration, string baseUrl)
|
||||||
|
{
|
||||||
|
Fragments = fragments;
|
||||||
|
Duration = duration;
|
||||||
|
BaseUrl = baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() =>
|
||||||
|
$"BaseUrl: {BaseUrl}\n" +
|
||||||
|
$"Duration: {Duration}\n" +
|
||||||
|
$"Fragments: HLSFragment[{Fragments.Length}]";
|
||||||
|
}
|
||||||
93
VkAudioDownloader/VkM3U8/M3U8Parser.cs
Normal file
93
VkAudioDownloader/VkM3U8/M3U8Parser.cs
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace VkAudioDownloader.VkM3U8;
|
||||||
|
|
||||||
|
public class M3U8Parser
|
||||||
|
{
|
||||||
|
private string _m3u8="";
|
||||||
|
private int _pos;
|
||||||
|
private List<HLSFragment> _fragments = new();
|
||||||
|
private int _playlistDuration = 0;
|
||||||
|
private HLSFragment _currentFragment = default;
|
||||||
|
|
||||||
|
// parses m3u8 playlist and resets state
|
||||||
|
public HLSPlaylist Parse(Uri m3u8Url, string m3u8Content)
|
||||||
|
{
|
||||||
|
_m3u8 = m3u8Content;
|
||||||
|
var line = NextLine();
|
||||||
|
while (!line.IsEmpty)
|
||||||
|
{
|
||||||
|
if (line.Contains('#'))
|
||||||
|
ParseHashTag(line);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_currentFragment.Name = line.ToString();
|
||||||
|
_fragments.Add(_currentFragment);
|
||||||
|
_currentFragment = default;
|
||||||
|
}
|
||||||
|
|
||||||
|
line = NextLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
string urlStr = m3u8Url.ToString();
|
||||||
|
var rezult = new HLSPlaylist(
|
||||||
|
_fragments.ToArray(),
|
||||||
|
_playlistDuration,
|
||||||
|
urlStr.Remove(urlStr.LastIndexOf('/')+1));
|
||||||
|
Clear();
|
||||||
|
return rezult;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReadOnlySpan<char> NextLine()
|
||||||
|
{
|
||||||
|
int pos = _pos;
|
||||||
|
int index = _m3u8.IndexOf('\n', pos);
|
||||||
|
if (index == -1)
|
||||||
|
index = _m3u8.Length - _pos;
|
||||||
|
if (index == 0)
|
||||||
|
return ReadOnlySpan<char>.Empty;
|
||||||
|
_pos = index+1;
|
||||||
|
if (_m3u8[index - 1] == '\r')
|
||||||
|
index--; // skip /r
|
||||||
|
var line = _m3u8.AsSpan(pos, index - pos);
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ParseHashTag(ReadOnlySpan<char> line)
|
||||||
|
{
|
||||||
|
if(line.StartsWith("EXT-X-TARGETDURATION:"))
|
||||||
|
_playlistDuration=Int32.Parse(line.After(':'));
|
||||||
|
else if (line.StartsWith("#EXT-X-KEY:METHOD="))
|
||||||
|
{
|
||||||
|
var method = line.After("#EXT-X-KEY:METHOD=");
|
||||||
|
|
||||||
|
if (method.ToString() == "NONE")
|
||||||
|
{
|
||||||
|
_currentFragment.Encrypted = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var alg = method.Before(',');
|
||||||
|
if (alg.ToString() != "AES-128")
|
||||||
|
throw new Exception($"unknown encryption algorythm: {method}");
|
||||||
|
|
||||||
|
var keyUrl=method.After("URI=\"").Before('\"');
|
||||||
|
if (!keyUrl.StartsWith("http"))
|
||||||
|
throw new Exception($"key uri is not url: {keyUrl}");
|
||||||
|
|
||||||
|
// AES-128 which AudioDecryptor can decrypt
|
||||||
|
_currentFragment.Encrypted = true;
|
||||||
|
_currentFragment.EncryptionKeyUrl = keyUrl.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Clear()
|
||||||
|
{
|
||||||
|
_m3u8 = "";
|
||||||
|
_pos=0;
|
||||||
|
_fragments.Clear();
|
||||||
|
_currentFragment = default;
|
||||||
|
_playlistDuration = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
VkAudioDownloader/VkM3U8/SpanHelper.cs
Normal file
39
VkAudioDownloader/VkM3U8/SpanHelper.cs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace VkAudioDownloader.VkM3U8;
|
||||||
|
|
||||||
|
internal static class SpanHelper
|
||||||
|
{
|
||||||
|
public static ReadOnlySpan<char> After(this ReadOnlySpan<char> span, char c)
|
||||||
|
{
|
||||||
|
var index = span.IndexOf(c);
|
||||||
|
if (index == -1)
|
||||||
|
throw new Exception($"char <{c}> not found in span <{span}>");
|
||||||
|
return span.Slice(index+1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ReadOnlySpan<char> After(this ReadOnlySpan<char> span, ReadOnlySpan<char> s)
|
||||||
|
{
|
||||||
|
var index = span.IndexOf(s);
|
||||||
|
if (index == -1)
|
||||||
|
throw new Exception($"span <{s}> not found in span <{span}>");
|
||||||
|
return span.Slice(index+s.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static ReadOnlySpan<char> Before(this ReadOnlySpan<char> span, char c)
|
||||||
|
{
|
||||||
|
var index = span.IndexOf(c);
|
||||||
|
if (index == -1)
|
||||||
|
throw new Exception($"char <{c}> not found in span <{span}>");
|
||||||
|
return span.Slice(0,index);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ReadOnlySpan<char> Before(this ReadOnlySpan<char> span, ReadOnlySpan<char> s)
|
||||||
|
{
|
||||||
|
var index = span.IndexOf(s);
|
||||||
|
if (index == -1)
|
||||||
|
throw new Exception($"span <{s}> not found in span <{span}>");
|
||||||
|
return span.Slice(0,index);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user