@@ -91,7 +91,11 @@ await Task.Factory.StartNew(() =>
9191 bool InitialRequest = true ;
9292 long currentLocation = 0 ;
9393 long StartLiveTime = card . live_time . Value ;
94- string lastMapUri = string . Empty ;
94+ //上一轮init segment的解码配置签名(视频宽高/编码参数、音频采样率/声道/AudioSpecificConfig)。
95+ //注意:不能按Map_URI文件名或init segment逐字节内容比较——部分CDN的m3u8中每个分片都有
96+ //独立的、按秒递增命名的EXT-X-MAP,且init segment里的码率统计等字段会周期性抖动,
97+ //按文件名或全字节比较都会误判为init segment变化,造成无限切割产生大量碎片文件
98+ byte [ ] lastInitSignature = null ;
9599 //最后一次下载到新分片的时间,用于"无新分片"看门狗
96100 DateTime lastSegmentTime = DateTime . Now ;
97101
@@ -219,7 +223,6 @@ await Task.Factory.StartNew(() =>
219223 string DebugFile = string . Empty ;
220224 //DebugFile = $"{hostClass.eXTM3U.eXTINFs[0].FileName+"_"+hostClass.eXTM3U.Map_URI.Replace(".m4s","_I.m4s")}";
221225 downloadSizeForThisCycle += WriteToFile ( fs , $ "{ hostClass . host } { hostClass . base_url } { hostClass . eXTM3U . Map_URI } ?{ hostClass . extra } ", DebugFile ) ;
222- lastMapUri = hostClass . eXTM3U . Map_URI ;
223226 }
224227 try
225228 {
@@ -248,29 +251,33 @@ await Task.Factory.StartNew(() =>
248251 hlsState = DownloadTaskState . Success ;
249252 return ;
250253 }
254+ //提取init segment解码配置签名并比较:只有宽高/编码参数/音频参数真正变化(主播连麦/重新推流)才切割
255+ byte [ ] initSignature = GetInitSegmentSignature ( m4sBytes ) ;
256+ if ( initSignature != null )
257+ {
258+ if ( lastInitSignature != null && ! initSignature . SequenceEqual ( lastInitSignature ) )
259+ {
260+ Log . Info ( nameof ( DlwnloadHls_avc_mp4 ) , $ "[{ card . Name } ({ card . RoomId } )]检测到推流编码参数变化(可能是主播连麦或重新推流),进行切割处理") ;
261+ hlsState = DownloadTaskState . Success ;
262+ return ;
263+ }
264+ lastInitSignature = initSignature ;
265+ }
251266 }
252267 else
253268 {
254- //分辨率检测是可选增强逻辑 ,init segment 异常时仅跳过本轮检测,不影响录制
269+ //分辨率/init segment内容检测是可选增强逻辑 ,init segment 异常时仅跳过本轮检测,不影响录制
255270 string skipReason = m4sBytes == null
256271 ? "init segment下载失败(未获取到数据,可能为网络问题或CDN拒绝请求)"
257272 : m4sBytes . Length < 248
258273 ? $ "init segment响应内容过短(仅{ m4sBytes . Length } 字节,可能为CDN错误页或响应被截断)"
259274 : "init segment内容不是合法的m4s文件(缺少ftyp文件头,可能为CDN错误页)" ;
260- Log . Warn ( nameof ( DlwnloadHls_avc_mp4 ) , $ "[{ card . Name } ({ card . RoomId } )]本轮分辨率变化检测已跳过 :{ skipReason } ,录制不受影响,下一轮将自动重试") ;
275+ Log . Warn ( nameof ( DlwnloadHls_avc_mp4 ) , $ "[{ card . Name } ({ card . RoomId } )]本轮分辨率/init segment变化检测已跳过 :{ skipReason } ,录制不受影响,下一轮将自动重试") ;
261276 }
262277 }
263278 catch ( Exception ex )
264279 {
265- Log . Warn ( nameof ( DlwnloadHls_avc_mp4 ) , $ "[{ card . Name } ({ card . RoomId } )]本轮分辨率变化检测已跳过:下载或解析init segment时发生异常({ ex . Message } ),录制不受影响", ex ) ;
266- }
267-
268- // 检测 init segment 是否变化(主播连麦/重新推流后 B站会发送新的 init segment)
269- if ( ! InitialRequest && ! string . IsNullOrEmpty ( lastMapUri ) && hostClass . eXTM3U . Map_URI != lastMapUri )
270- {
271- Log . Info ( nameof ( DlwnloadHls_avc_mp4 ) , $ "[{ card . Name } ({ card . RoomId } )]检测到 init segment 变化(可能是主播连麦或重新推流),进行切割处理") ;
272- hlsState = DownloadTaskState . Success ;
273- return ;
280+ Log . Warn ( nameof ( DlwnloadHls_avc_mp4 ) , $ "[{ card . Name } ({ card . RoomId } )]本轮分辨率/init segment变化检测已跳过:下载或解析init segment时发生异常({ ex . Message } ),录制不受影响", ex ) ;
274281 }
275282
276283 if ( currentLocation != 0 && Core . Config . Core_RunConfig . _ReconnectAnchorReStream )
@@ -407,6 +414,205 @@ private static bool TryParseResolution(byte[] m4sBytes, out long width, out long
407414 return true ;
408415 }
409416
417+ /// <summary>
418+ /// 从init segment(m4s)提取解码配置签名:视频宽高+编码配置(avcC/hvcC/av1C)内容、音频声道数/采样率/AudioSpecificConfig。
419+ /// 只有这些影响分片拼接解码的字段变化才视为推流参数真正变化;
420+ /// B站CDN下发的init segment中码率统计等字段会周期性抖动,逐字节比较会误判
421+ /// </summary>
422+ /// <param name="m4sBytes">init segment字节内容</param>
423+ /// <returns>签名字节;内容缺失、过短或不是合法m4s时返回null</returns>
424+ private static byte [ ] GetInitSegmentSignature ( byte [ ] m4sBytes )
425+ {
426+ if ( m4sBytes == null || m4sBytes . Length < 248 )
427+ {
428+ return null ;
429+ }
430+ if ( m4sBytes [ 4 ] != 'f' || m4sBytes [ 5 ] != 't' || m4sBytes [ 6 ] != 'y' || m4sBytes [ 7 ] != 'p' )
431+ {
432+ return null ;
433+ }
434+ using MemoryStream signature = new ( ) ;
435+ //视频轨道宽高(tkhd固定偏移,与TryParseResolution一致)
436+ signature . Write ( m4sBytes , 240 , 8 ) ;
437+ foreach ( var moov in EnumerateChildBoxes ( m4sBytes , 0 , m4sBytes . Length ) )
438+ {
439+ if ( moov . type != "moov" )
440+ {
441+ continue ;
442+ }
443+ foreach ( var trak in EnumerateChildBoxes ( m4sBytes , moov . contentStart , moov . end ) )
444+ {
445+ if ( trak . type != "trak" )
446+ {
447+ continue ;
448+ }
449+ //逐层下钻到stsd,取第一个sample entry
450+ var mdia = FindChildBox ( m4sBytes , trak . contentStart , trak . end , "mdia" ) ;
451+ var minf = mdia == null ? null : FindChildBox ( m4sBytes , mdia . Value . contentStart , mdia . Value . end , "minf" ) ;
452+ var stbl = minf == null ? null : FindChildBox ( m4sBytes , minf . Value . contentStart , minf . Value . end , "stbl" ) ;
453+ var stsd = stbl == null ? null : FindChildBox ( m4sBytes , stbl . Value . contentStart , stbl . Value . end , "stsd" ) ;
454+ if ( stsd == null )
455+ {
456+ continue ;
457+ }
458+ //stsd内容: version/flags(4) + entry_count(4) + sample entry列表
459+ int entryStart = stsd . Value . contentStart + 8 ;
460+ if ( entryStart + 8 > stsd . Value . end )
461+ {
462+ continue ;
463+ }
464+ int entrySize = ReadBoxSize ( m4sBytes , entryStart ) ;
465+ string entryType = Encoding . ASCII . GetString ( m4sBytes , entryStart + 4 , 4 ) ;
466+ int entryContent = entryStart + 8 ;
467+ int entryEnd = entryStart + entrySize ;
468+ if ( entrySize < 8 || entryEnd > stsd . Value . end )
469+ {
470+ continue ;
471+ }
472+ if ( entryType is "avc1" or "avc2" or "avc3" or "avc4" or "hev1" or "hvc1" or "av01" )
473+ {
474+ //VisualSampleEntry固定头部78字节,之后是编码配置等子box
475+ foreach ( var child in EnumerateChildBoxes ( m4sBytes , entryContent + 78 , entryEnd ) )
476+ {
477+ if ( child . type is "avcC" or "hvcC" or "av1C" )
478+ {
479+ signature . Write ( m4sBytes , child . contentStart , child . end - child . contentStart ) ;
480+ }
481+ }
482+ }
483+ else if ( entryType == "mp4a" )
484+ {
485+ //AudioSampleEntry固定头部: reserved(6)+data_reference_index(2)+reserved(8)+channelcount(2)+samplesize(2)+pre_defined(2)+reserved(2)+samplerate(4)
486+ if ( entryContent + 28 <= entryEnd )
487+ {
488+ signature . Write ( m4sBytes , entryContent + 16 , 2 ) ; //channelcount
489+ signature . Write ( m4sBytes , entryContent + 24 , 4 ) ; //samplerate(16.16定点数)
490+ foreach ( var child in EnumerateChildBoxes ( m4sBytes , entryContent + 28 , entryEnd ) )
491+ {
492+ if ( child . type == "esds" )
493+ {
494+ byte [ ] asc = FindAudioSpecificConfig ( m4sBytes , child . contentStart , child . end ) ;
495+ if ( asc != null )
496+ {
497+ signature . Write ( asc , 0 , asc . Length ) ;
498+ }
499+ }
500+ }
501+ }
502+ }
503+ }
504+ }
505+ return signature . ToArray ( ) ;
506+ }
507+
508+ /// <summary>
509+ /// 在指定范围内查找第一个指定类型的MP4子box
510+ /// </summary>
511+ private static ( int contentStart , int end ) ? FindChildBox ( byte [ ] buf , int start , int end , string type )
512+ {
513+ foreach ( var box in EnumerateChildBoxes ( buf , start , end ) )
514+ {
515+ if ( box . type == type )
516+ {
517+ return ( box . contentStart , box . end ) ;
518+ }
519+ }
520+ return null ;
521+ }
522+
523+ /// <summary>
524+ /// 枚举指定范围内的MP4子box
525+ /// </summary>
526+ private static IEnumerable < ( int contentStart , int end , string type ) > EnumerateChildBoxes ( byte [ ] buf , int start , int end )
527+ {
528+ int i = start ;
529+ while ( i + 8 <= end )
530+ {
531+ int size = ReadBoxSize ( buf , i ) ;
532+ if ( size < 8 || i + size > end )
533+ {
534+ yield break ;
535+ }
536+ yield return ( i + 8 , i + size , Encoding . ASCII . GetString ( buf , i + 4 , 4 ) ) ;
537+ i += size ;
538+ }
539+ }
540+
541+ /// <summary>
542+ /// 读取MP4 box的size字段(大端32位)
543+ /// </summary>
544+ private static int ReadBoxSize ( byte [ ] buf , int offset )
545+ {
546+ return ( int ) ( ( uint ) buf [ offset ] << 24 | ( uint ) buf [ offset + 1 ] << 16 | ( uint ) buf [ offset + 2 ] << 8 | buf [ offset + 3 ] ) ;
547+ }
548+
549+ /// <summary>
550+ /// 从esds box内容中解析DecoderSpecificInfo(tag 0x05,即AudioSpecificConfig)。
551+ /// 注意DecoderConfigDescriptor里的maxBitrate/avgBitrate等码率统计字段会随推流抖动,
552+ /// 不能把esds整体直接参与"init segment是否变化"的比较
553+ /// </summary>
554+ private static byte [ ] FindAudioSpecificConfig ( byte [ ] buf , int start , int end )
555+ {
556+ //esds内容: version/flags(4) + ES_Descriptor(tag 0x03)
557+ foreach ( var esDesc in EnumerateDescriptors ( buf , start + 4 , end ) )
558+ {
559+ if ( esDesc . tag != 0x03 || esDesc . length < 3 )
560+ {
561+ continue ;
562+ }
563+ //ES_Descriptor: ES_ID(2) + flags(1) + 子描述符
564+ foreach ( var decoderConfig in EnumerateDescriptors ( buf , esDesc . payloadStart + 3 , esDesc . payloadStart + esDesc . length ) )
565+ {
566+ if ( decoderConfig . tag != 0x04 || decoderConfig . length < 13 )
567+ {
568+ continue ;
569+ }
570+ //DecoderConfigDescriptor: objectType(1)+streamType(1)+bufferSizeDB(3)+maxBitrate(4)+avgBitrate(4) + 子描述符
571+ foreach ( var specific in EnumerateDescriptors ( buf , decoderConfig . payloadStart + 13 , decoderConfig . payloadStart + decoderConfig . length ) )
572+ {
573+ if ( specific . tag == 0x05 )
574+ {
575+ byte [ ] asc = new byte [ specific . length ] ;
576+ Array . Copy ( buf , specific . payloadStart , asc , 0 , specific . length ) ;
577+ return asc ;
578+ }
579+ }
580+ }
581+ }
582+ return null ;
583+ }
584+
585+ /// <summary>
586+ /// 枚举MPEG-4描述符(tag + 可扩展length + payload)
587+ /// </summary>
588+ private static IEnumerable < ( int tag , int payloadStart , int length ) > EnumerateDescriptors ( byte [ ] buf , int start , int end )
589+ {
590+ int i = start ;
591+ while ( i + 2 <= end )
592+ {
593+ int tag = buf [ i ++ ] ;
594+ int length = 0 ;
595+ int lengthBytes = 0 ;
596+ bool continues ;
597+ do
598+ {
599+ if ( i >= end || ++ lengthBytes > 4 )
600+ {
601+ yield break ;
602+ }
603+ byte b = buf [ i ++ ] ;
604+ length = ( length << 7 ) | ( b & 0x7F ) ;
605+ continues = ( b & 0x80 ) != 0 ;
606+ } while ( continues ) ;
607+ if ( i + length > end )
608+ {
609+ yield break ;
610+ }
611+ yield return ( tag , i , length ) ;
612+ i += length ;
613+ }
614+ }
615+
410616 /// <summary>
411617 /// 处理Host刷新
412618 /// </summary>
0 commit comments