初步支持直播录制

This commit is contained in:
nilaoda 2022-09-17 23:17:27 +08:00
parent e3bbdde016
commit 1429f3c14b
15 changed files with 267 additions and 32 deletions

View File

@ -52,8 +52,15 @@ namespace N_m3u8DL_RE.Common.Resource
public static string cmd_urlProcessorArgs { get => GetText("cmd_urlProcessorArgs"); }
public static string cmd_useShakaPackager { get => GetText("cmd_useShakaPackager"); }
public static string cmd_concurrentDownload { get => GetText("cmd_concurrentDownload"); }
public static string cmd_liveKeepSegments { get => GetText("cmd_liveKeepSegments"); }
public static string cmd_liveRecordLimit { get => GetText("cmd_liveRecordLimit"); }
public static string cmd_liveRealTimeMerge { get => GetText("cmd_liveRealTimeMerge"); }
public static string cmd_livePerformAsVod { get => GetText("cmd_livePerformAsVod"); }
public static string cmd_muxAfterDone { get => GetText("cmd_muxAfterDone"); }
public static string cmd_writeMetaJson { get => GetText("cmd_writeMetaJson"); }
public static string liveLimit { get => GetText("liveLimit"); }
public static string liveLimitReached { get => GetText("liveLimitReached"); }
public static string saveName { get => GetText("saveName"); }
public static string fetch { get => GetText("fetch"); }
public static string ffmpegMerge { get => GetText("ffmpegMerge"); }
public static string ffmpegNotFound { get => GetText("ffmpegNotFound"); }

View File

@ -232,6 +232,30 @@ namespace N_m3u8DL_RE.Common.Resource
zhTW: "此字符串將直接傳遞給URL Processor",
enUS: "Give these arguments to the URL Processors."
),
["cmd_liveRealTimeMerge"] = new TextContainer
(
zhCN: "录制直播时实时合并",
zhTW: "錄製直播時即時合併",
enUS: "Real-time merge into file when recording live"
),
["cmd_livePerformAsVod"] = new TextContainer
(
zhCN: "以点播方式下载直播流",
zhTW: "以點播方式下載直播流",
enUS: "Download live streams as vod"
),
["cmd_liveKeepSegments"] = new TextContainer
(
zhCN: "录制直播并开启实时合并时依然保留分片",
zhTW: "錄製直播並開啟即時合併時依然保留分片",
enUS: "Keep segments when recording a live broadcast and enable liveRealTimeMerge"
),
["cmd_liveRecordLimit"] = new TextContainer
(
zhCN: "录制直播时的录制时长限制",
zhTW: "錄製直播時的錄製時長限制",
enUS: "Recording time limit when recording live"
),
["cmd_useShakaPackager"] = new TextContainer
(
zhCN: "解密时使用shaka-packager替代mp4decrypt",
@ -397,6 +421,24 @@ namespace N_m3u8DL_RE.Common.Resource
zhTW: "解析後的訊息是否輸出json文件",
enUS: "Write meta json after parsed"
),
["liveLimit"] = new TextContainer
(
zhCN: "本次直播录制时长上限: ",
zhTW: "本次直播錄製時長上限: ",
enUS: "Live recording duration limit: "
),
["liveLimitReached"] = new TextContainer
(
zhCN: "到达直播录制上限,即将停止录制",
zhTW: "到達直播錄製上限,即將停止錄製",
enUS: "Live recording limit reached, will stop recording soon"
),
["saveName"] = new TextContainer
(
zhCN: "保存文件名: ",
zhTW: "保存檔案名: ",
enUS: "Save Name: "
),
["fetch"] = new TextContainer
(
zhCN: "获取: ",

View File

@ -1,5 +1,6 @@
using N_m3u8DL_RE.Common.Entity;
using N_m3u8DL_RE.Common.Enum;
using N_m3u8DL_RE.Common.Util;
using N_m3u8DL_RE.Parser.Config;
using N_m3u8DL_RE.Parser.Constants;
using N_m3u8DL_RE.Parser.Util;
@ -471,10 +472,23 @@ namespace N_m3u8DL_RE.Parser.Extractor
return streamList;
}
public async Task FetchPlayListAsync(List<StreamSpec> streamSpecs)
public async Task RefreshPlayListAsync(List<StreamSpec> streamSpecs)
{
if (streamSpecs.Count == 0) return;
var rawText = await HTTPUtil.GetWebSourceAsync(ParserConfig.Url);
var newStreams = await ExtractStreamsAsync(rawText);
foreach (var streamSpec in streamSpecs)
{
var match = newStreams.Where(n => n.ToShortString() == streamSpec.ToShortString());
if (match.Any())
streamSpec.Playlist = match.First().Playlist;
}
//这里才调用URL预处理器节省开销
await ProcessUrlAsync(streamSpecs);
}
private async Task ProcessUrlAsync(List<StreamSpec> streamSpecs)
{
for (int i = 0; i < streamSpecs.Count; i++)
{
var playlist = streamSpecs[i].Playlist;
@ -496,6 +510,12 @@ namespace N_m3u8DL_RE.Parser.Extractor
}
}
public async Task FetchPlayListAsync(List<StreamSpec> streamSpecs)
{
//这里才调用URL预处理器节省开销
await ProcessUrlAsync(streamSpecs);
}
public string PreProcessUrl(string url)
{
foreach (var p in ParserConfig.UrlProcessors)

View File

@ -536,5 +536,10 @@ namespace N_m3u8DL_RE.Parser.Extractor
}
}
}
public async Task RefreshPlayListAsync(List<StreamSpec> streamSpecs)
{
await FetchPlayListAsync(streamSpecs);
}
}
}

View File

@ -18,6 +18,7 @@ namespace N_m3u8DL_RE.Parser.Extractor
Task<List<StreamSpec>> ExtractStreamsAsync(string rawText);
Task FetchPlayListAsync(List<StreamSpec> streamSpecs);
Task RefreshPlayListAsync(List<StreamSpec> streamSpecs);
string PreProcessUrl(string url);

View File

@ -5,12 +5,14 @@ using N_m3u8DL_RE.Common.Resource;
using N_m3u8DL_RE.Parser.Constants;
using N_m3u8DL_RE.Parser.Extractor;
using N_m3u8DL_RE.Common.Util;
using N_m3u8DL_RE.Common.Enum;
namespace N_m3u8DL_RE.Parser
{
public class StreamExtractor
{
public IExtractor Extractor { get; private set; }
public ExtractorType ExtractorType { get => extractor.ExtractorType; }
private IExtractor extractor;
private ParserConfig parserConfig = new ParserConfig();
private string rawText;
private static SemaphoreSlim semaphore = new SemaphoreSlim(1, 1);
@ -56,13 +58,13 @@ namespace N_m3u8DL_RE.Parser
if (rawText.StartsWith(HLSTags.ext_m3u))
{
Logger.InfoMarkUp(ResString.matchHLS);
Extractor = new HLSExtractor(parserConfig);
extractor = new HLSExtractor(parserConfig);
}
else if (rawText.Contains("</MPD>") && rawText.Contains("<MPD"))
{
Logger.InfoMarkUp(ResString.matchDASH);
//extractor = new DASHExtractor(parserConfig);
Extractor = new DASHExtractor2(parserConfig);
extractor = new DASHExtractor2(parserConfig);
}
else
{
@ -80,7 +82,7 @@ namespace N_m3u8DL_RE.Parser
{
await semaphore.WaitAsync();
Logger.Info(ResString.parsingStream);
return await Extractor.ExtractStreamsAsync(rawText);
return await extractor.ExtractStreamsAsync(rawText);
}
finally
{
@ -98,7 +100,20 @@ namespace N_m3u8DL_RE.Parser
{
await semaphore.WaitAsync();
Logger.Info(ResString.parsingStream);
await Extractor.FetchPlayListAsync(streamSpecs);
await extractor.FetchPlayListAsync(streamSpecs);
}
finally
{
semaphore.Release();
}
}
public async Task RefreshPlayListAsync(List<StreamSpec> streamSpecs)
{
try
{
await semaphore.WaitAsync();
await extractor.RefreshPlayListAsync(streamSpecs);
}
finally
{

View File

@ -0,0 +1,27 @@
using N_m3u8DL_RE.Common.Util;
using Spectre.Console;
using Spectre.Console.Rendering;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace N_m3u8DL_RE.Column
{
internal class RecordingDurationColumn : ProgressColumn
{
protected override bool NoWrap => true;
private ConcurrentDictionary<int, int> _recodingDurDic;
public Style MyStyle { get; set; } = new Style(foreground: Color.Grey);
public RecordingDurationColumn(ConcurrentDictionary<int, int> recodingDurDic)
{
_recodingDurDic = recodingDurDic;
}
public override IRenderable Render(RenderContext context, ProgressTask task, TimeSpan deltaTime)
{
return new Text(GlobalUtil.FormatTime(_recodingDurDic[task.Id]), MyStyle).LeftAligned();
}
}
}

View File

@ -0,0 +1,23 @@
using Spectre.Console;
using Spectre.Console.Rendering;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace N_m3u8DL_RE.Column
{
internal class RecordingStatusColumn : ProgressColumn
{
protected override bool NoWrap => true;
public Style MyStyle { get; set; } = new Style(foreground: Color.Default);
public Style FinishedStyle { get; set; } = new Style(foreground: Color.Yellow);
public override IRenderable Render(RenderContext context, ProgressTask task, TimeSpan deltaTime)
{
if (task.IsFinished)
return new Text($"{task.Value}/{task.MaxValue} Waiting ", FinishedStyle).LeftAligned();
return new Text($"{task.Value}/{task.MaxValue} Recording", MyStyle).LeftAligned();
}
}
}

View File

@ -3,6 +3,7 @@ using N_m3u8DL_RE.Common.Resource;
using N_m3u8DL_RE.Entity;
using N_m3u8DL_RE.Enum;
using N_m3u8DL_RE.Util;
using NiL.JS.Expressions;
using System.CommandLine;
using System.CommandLine.Binding;
using System.CommandLine.Parsing;
@ -47,6 +48,13 @@ namespace N_m3u8DL_RE.CommandLine
private readonly static Option<string?> BaseUrl = new(new string[] { "--base-url" }, description: ResString.cmd_baseUrl);
private readonly static Option<bool> ConcurrentDownload = new(new string[] { "-mt", "--concurrent-download" }, description: ResString.cmd_concurrentDownload, getDefaultValue: () => false);
//直播相关
private readonly static Option<bool> LivePerformAsVod = new(new string[] { "--live-perform-as-vod" }, description: ResString.cmd_livePerformAsVod, getDefaultValue: () => false);
private readonly static Option<bool> LiveRealTimeMerge = new(new string[] { "--live-real-time-merge" }, description: ResString.cmd_liveRealTimeMerge, getDefaultValue: () => false);
private readonly static Option<bool> LiveKeepSegments = new(new string[] { "--live-keep-segments" }, description: ResString.cmd_liveKeepSegments, getDefaultValue: () => true);
private readonly static Option<TimeSpan?> LiveRecordLimit = new(new string[] { "--live-record-limit" }, description: ResString.cmd_liveRecordLimit, parseArgument: ParseLiveLimit) { ArgumentHelpName = "HH:mm:ss" };
//复杂命令行如下
private readonly static Option<MuxOptions?> MuxAfterDone = new(new string[] { "-M", "--mux-after-done" }, description: ResString.cmd_muxAfterDone, parseArgument: ParseMuxAfterDone) { ArgumentHelpName = "OPTIONS" };
private readonly static Option<List<OutputFile>> MuxImports = new("--mux-import", description: ResString.cmd_muxImport, parseArgument: ParseImports) { Arity = ArgumentArity.OneOrMore, AllowMultipleArgumentsPerToken = false, ArgumentHelpName = "OPTIONS" };
@ -54,11 +62,29 @@ namespace N_m3u8DL_RE.CommandLine
private readonly static Option<StreamFilter?> AudioFilter = new(new string[] { "-sa", "--select-audio" }, description: ResString.cmd_selectAudio, parseArgument: ParseStreamFilter) { ArgumentHelpName = "OPTIONS" };
private readonly static Option<StreamFilter?> SubtitleFilter = new(new string[] { "-ss", "--select-subtitle" }, description: ResString.cmd_selectSubtitle, parseArgument: ParseStreamFilter) { ArgumentHelpName = "OPTIONS" };
/// <summary>
/// 解析录制直播时长限制
/// </summary>
/// <param name="result"></param>
/// <returns></returns>
private static TimeSpan? ParseLiveLimit(ArgumentResult result)
{
var input = result.Tokens.First().Value;
try
{
return OtherUtil.ParseDur(input);
}
catch (Exception)
{
result.ErrorMessage = "error in parse LiveRecordLimit: " + input;
return null;
}
}
private static string? ParseSaveName(ArgumentResult result)
{
var input = result.Tokens.First().Value;
var newName = ConvertUtil.GetValidFileName(input);
var newName = OtherUtil.GetValidFileName(input);
if (string.IsNullOrEmpty(newName))
{
result.ErrorMessage = "Invalid save name!";
@ -143,7 +169,7 @@ namespace N_m3u8DL_RE.CommandLine
private static Dictionary<string, string> ParseHeaders(ArgumentResult result)
{
var array = result.Tokens.Select(t => t.Value).ToArray();
return ConvertUtil.SplitHeaderArrayToDic(array);
return OtherUtil.SplitHeaderArrayToDic(array);
}
/// <summary>
@ -269,6 +295,10 @@ namespace N_m3u8DL_RE.CommandLine
VideoFilter = bindingContext.ParseResult.GetValueForOption(VideoFilter),
AudioFilter = bindingContext.ParseResult.GetValueForOption(AudioFilter),
SubtitleFilter = bindingContext.ParseResult.GetValueForOption(SubtitleFilter),
LiveRealTimeMerge = bindingContext.ParseResult.GetValueForOption(LiveRealTimeMerge),
LiveKeepSegments = bindingContext.ParseResult.GetValueForOption(LiveKeepSegments),
LiveRecordLimit = bindingContext.ParseResult.GetValueForOption(LiveRecordLimit),
LivePerformAsVod = bindingContext.ParseResult.GetValueForOption(LivePerformAsVod),
};
var parsedHeaders = bindingContext.ParseResult.GetValueForOption(Headers);
@ -304,13 +334,15 @@ namespace N_m3u8DL_RE.CommandLine
public static async Task<int> InvokeArgs(string[] args, Func<MyOption, Task> action)
{
var rootCommand = new RootCommand("N_m3u8DL-RE (Beta version) 20220914")
var rootCommand = new RootCommand("N_m3u8DL-RE (Beta version) 20220917")
{
Input, TmpDir, SaveDir, SaveName, BaseUrl, ThreadCount, DownloadRetryCount, AutoSelect, SkipMerge, SkipDownload, CheckSegmentsCount,
BinaryMerge, DelAfterDone, WriteMetaJson, AppendUrlParams, ConcurrentDownload, Headers, /**SavePattern,**/ SubOnly, SubtitleFormat, AutoSubtitleFix,
FFmpegBinaryPath,
LogLevel, UILanguage, UrlProcessorArgs, Keys, KeyTextFile, DecryptionBinaryPath, UseShakaPackager, MP4RealTimeDecryption,
MuxAfterDone, MuxImports, VideoFilter, AudioFilter, SubtitleFilter
MuxAfterDone,
LivePerformAsVod, LiveRealTimeMerge, LiveKeepSegments, LiveRecordLimit,
MuxImports, VideoFilter, AudioFilter, SubtitleFilter
};
rootCommand.TreatUnmatchedTokensAsErrors = true;
rootCommand.SetHandler(async (myOption) => await action(myOption), new MyOptionBinder());

View File

@ -51,6 +51,10 @@ namespace N_m3u8DL_RE.CommandLine
/// </summary>
public int DownloadRetryCount { get; set; }
/// <summary>
/// See: <see cref="CommandInvoker.LiveRecordLimit"/>.
/// </summary>
public TimeSpan? LiveRecordLimit { get; set; }
/// <summary>
/// See: <see cref="CommandInvoker.SkipMerge"/>.
/// </summary>
public bool SkipMerge { get; set; }
@ -107,6 +111,18 @@ namespace N_m3u8DL_RE.CommandLine
/// </summary>
public bool ConcurrentDownload { get; set; }
/// <summary>
/// See: <see cref="CommandInvoker.LiveRealTimeMerge"/>.
/// </summary>
public bool LiveRealTimeMerge { get; set; }
/// <summary>
/// See: <see cref="CommandInvoker.LiveKeepSegments"/>.
/// </summary>
public bool LiveKeepSegments { get; set; }
/// <summary>
/// See: <see cref="CommandInvoker.LivePerformAsVod"/>.
/// </summary>
public bool LivePerformAsVod { get; set; }
/// <summary>
/// See: <see cref="CommandInvoker.SubtitleFormat"/>.
/// </summary>
public SubtitleFormat SubtitleFormat { get; set; }

View File

@ -9,7 +9,6 @@
<IlcTrimMetadata>true</IlcTrimMetadata>
<IlcGenerateStackTraceData>true</IlcGenerateStackTraceData>
<SatelliteResourceLanguages>zh-CN;zh-TW;en-US</SatelliteResourceLanguages>
<NativeAotCompilerVersion>7.0.0-*</NativeAotCompilerVersion>
<PublishAot>true</PublishAot>
</PropertyGroup>

View File

@ -367,7 +367,7 @@ namespace N_m3u8DL_RE.DownloadManager
if (DownloaderConfig.MyOptions.SubtitleFormat != Enum.SubtitleFormat.VTT)
{
path = Path.ChangeExtension(path, ".srt");
subContentFixed = ConvertUtil.WebVtt2Other(finalVtt, DownloaderConfig.MyOptions.SubtitleFormat);
subContentFixed = OtherUtil.WebVtt2Other(finalVtt, DownloaderConfig.MyOptions.SubtitleFormat);
output = Path.ChangeExtension(output, ".srt");
}
await File.WriteAllTextAsync(path, subContentFixed, new UTF8Encoding(false));
@ -402,7 +402,7 @@ namespace N_m3u8DL_RE.DownloadManager
if (DownloaderConfig.MyOptions.SubtitleFormat != Enum.SubtitleFormat.VTT)
{
path = Path.ChangeExtension(path, ".srt");
subContentFixed = ConvertUtil.WebVtt2Other(finalVtt, DownloaderConfig.MyOptions.SubtitleFormat);
subContentFixed = OtherUtil.WebVtt2Other(finalVtt, DownloaderConfig.MyOptions.SubtitleFormat);
output = Path.ChangeExtension(output, ".srt");
}
await File.WriteAllTextAsync(path, subContentFixed, new UTF8Encoding(false));
@ -435,7 +435,7 @@ namespace N_m3u8DL_RE.DownloadManager
if (DownloaderConfig.MyOptions.SubtitleFormat != Enum.SubtitleFormat.VTT)
{
path = Path.ChangeExtension(path, ".srt");
subContentFixed = ConvertUtil.WebVtt2Other(finalVtt, DownloaderConfig.MyOptions.SubtitleFormat);
subContentFixed = OtherUtil.WebVtt2Other(finalVtt, DownloaderConfig.MyOptions.SubtitleFormat);
output = Path.ChangeExtension(output, ".srt");
}
await File.WriteAllTextAsync(path, subContentFixed, new UTF8Encoding(false));
@ -472,7 +472,7 @@ namespace N_m3u8DL_RE.DownloadManager
if (DownloaderConfig.MyOptions.SubtitleFormat != Enum.SubtitleFormat.VTT)
{
path = Path.ChangeExtension(path, ".srt");
subContentFixed = ConvertUtil.WebVtt2Other(finalVtt, DownloaderConfig.MyOptions.SubtitleFormat);
subContentFixed = OtherUtil.WebVtt2Other(finalVtt, DownloaderConfig.MyOptions.SubtitleFormat);
output = Path.ChangeExtension(output, ".srt");
}
await File.WriteAllTextAsync(path, subContentFixed, new UTF8Encoding(false));

View File

@ -186,7 +186,7 @@ namespace N_m3u8DL_RE
var streams = await extractor.ExtractStreamsAsync();
//直播检测
var livingFlag = streams.Any(s => s.Playlist?.IsLive == true);
var livingFlag = streams.Any(s => s.Playlist?.IsLive == true) && !option.LivePerformAsVod;
if (livingFlag)
{
Logger.WarnMarkUp($"[white on darkorange3_1]{ResString.liveFound}[/]");
@ -248,7 +248,7 @@ namespace N_m3u8DL_RE
//HLS: 选中流中若有没加载出playlist的加载playlist
//DASH: 加载playlist (调用url预处理器)
if (selectedStreams.Any(s => s.Playlist == null) || extractor.Extractor.ExtractorType == ExtractorType.MPEG_DASH)
if (selectedStreams.Any(s => s.Playlist == null) || extractor.ExtractorType == ExtractorType.MPEG_DASH)
await extractor.FetchPlayListAsync(selectedStreams);
//无法识别的加密方式,自动开启二进制合并
@ -281,19 +281,11 @@ namespace N_m3u8DL_RE
//尝试从URL或文件读取文件名
if (string.IsNullOrEmpty(option.SaveName))
{
if (File.Exists(option.Input))
{
option.SaveName = Path.GetFileNameWithoutExtension(option.Input) + "_" + DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss");
}
else
{
var uri = new Uri(option.Input);
var name = uri.GetLeftPart(UriPartial.Path).Split('/').Last();
name = string.Join(".", name.Split('.').SkipLast(1)).Trim('.');
option.SaveName = ConvertUtil.GetValidFileName(name) + "_" + DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss");
}
option.SaveName = OtherUtil.GetFileNameFromInput(option.Input);
}
Logger.InfoMarkUp(ResString.saveName + $"[deepskyblue1]{option.SaveName.EscapeMarkup()}[/]");
//下载配置
var downloadConfig = new DownloaderConfig()
{
@ -313,7 +305,12 @@ namespace N_m3u8DL_RE
}
else
{
throw new NotSupportedException("Live not supported yet.");
var sldm = new SimpleLiveRecordManager(downloadConfig, selectedStreams, extractor);
var result = await sldm.StartRecordAsync();
if (result)
Logger.InfoMarkUp("[white on green]Done[/]");
else
Logger.ErrorMarkUp("[white on red]Faild[/]");
}
}
catch (Exception ex)

View File

@ -1,11 +1,12 @@
using N_m3u8DL_RE.Common.Entity;
using N_m3u8DL_RE.Common.Log;
using N_m3u8DL_RE.Enum;
using System.CommandLine;
using System.Text;
namespace N_m3u8DL_RE.Util
{
internal class ConvertUtil
internal class OtherUtil
{
public static Dictionary<string,string> SplitHeaderArrayToDic(string[]? headers)
{
@ -66,5 +67,55 @@ namespace N_m3u8DL_RE.Util
}
return title.Trim('.');
}
/// <summary>
/// 从输入自动获取文件名
/// </summary>
/// <param name="input"></param>
/// <returns></returns>
public static string GetFileNameFromInput(string input, bool addSuffix = true)
{
var saveName = addSuffix ? DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss") : string.Empty;
if (File.Exists(input))
{
saveName = Path.GetFileNameWithoutExtension(input) + "_" + saveName;
}
else
{
var uri = new Uri(input);
var name = uri.GetLeftPart(UriPartial.Path).Split('/').Last();
name = string.Join(".", name.Split('.').SkipLast(1)).Trim('.');
saveName = GetValidFileName(name) + "_" + saveName;
}
return saveName;
}
/// <summary>
/// 从 hh:mm:ss 解析TimeSpan
/// </summary>
/// <param name="timeStr"></param>
/// <returns></returns>
public static TimeSpan ParseDur(string timeStr)
{
var arr = timeStr.Replace("", ":").Split(':');
var days = -1;
var hours = -1;
var mins = -1;
var secs = -1;
arr.Reverse().Select(i => Convert.ToInt32(i)).ToList().ForEach(item =>
{
if (secs == -1) secs = item;
else if (mins == -1) mins = item;
else if (hours == -1) hours = item;
else if (days == -1) days = item;
});
if (days == -1) days = 0;
if (hours == -1) hours = 0;
if (mins == -1) mins = 0;
if (secs == -1) secs = 0;
return new TimeSpan(days, hours, mins, secs);
}
}
}