Spaces:
Running
Running
Arena.ai Agent commited on
Commit ·
7ea6aba
1
Parent(s): c343d8e
v2.45: port dual ingest and probed canvas fixes
Browse files- README.md +30 -0
- ingest.py +1 -1
- relay.py +18 -3
- test_ingest_routes.py +16 -0
- test_smart_mode.py +5 -3
- test_source_probe.py +142 -0
- verify.sh +1 -0
- youtube_manager.py +54 -8
README.md
CHANGED
|
@@ -10,6 +10,36 @@ app_port: 7860
|
|
| 10 |
|
| 11 |
# Reoo88/On — relay-v2.29-smart-mode
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
## تنظيف تلقائي عند غياب البث في v2.27
|
| 14 |
|
| 15 |
عند غياب أي بث بشكل كامل ومستمر لمدة `YOUTUBE_IDLE_RESET_SECONDS` (الافتراضي
|
|
|
|
| 10 |
|
| 11 |
# Reoo88/On — relay-v2.29-smart-mode
|
| 12 |
|
| 13 |
+
## تثبيت الـcanvas على مقاس FLV الحقيقي في v2.45
|
| 14 |
+
|
| 15 |
+
أظهر القياس الدائم أن وصف مرشح TikTok قد يبقى على مقاس أقدم بينما ترويسة
|
| 16 |
+
الفيديو الفعلية مختلفة: مثلًا `432x864` في الوصف مقابل `640x1280` في
|
| 17 |
+
`STREAM_CONFIG`، و`540x960` مقابل `720x1280`. كان `matchgop` يثبّت الـcanvas
|
| 18 |
+
من الوصف قبل أن يصل المقاس الحقيقي، فيصغر الخرج من أول البث ويبقى ثابتًا.
|
| 19 |
+
|
| 20 |
+
أصبح فحص المصدر الموجود قبل الإرسال يقرأ أول سجل AVC/HEVC ضمن بادئة محدودة
|
| 21 |
+
(افتراضيًا 256 KiB و4 ثوانٍ، `YOUTUBE_FLV_PROBE_BYTES/SECONDS`). إذا احتوى
|
| 22 |
+
مقاسًا صالحًا، تُحفظ `probed_width/probed_height` بجانب المرشح ويستخدمها
|
| 23 |
+
`lock_gold` بدل الوصف. لا يتغير اسم الجودة أو تثبيت الـrendition. وإذا لم تظهر
|
| 24 |
+
ترويسة مقاس ضمن الحد، يبقى قبول توقيع FLV والسلوك السابق بلا تغيير.
|
| 25 |
+
|
| 26 |
+
هذا تصحيح لاختيار الـcanvas فقط؛ لا يغيّر `copy` أو `tee/global_header` أو
|
| 27 |
+
القص ومعدل البت، ولا يغيّر مهلة بدء YouTube أو الاسترداد. يغطي
|
| 28 |
+
`test_source_probe.py` التجزئة، والترويسة غير الصالحة أصغر من 32px، وعدم
|
| 29 |
+
إعادة كتابة وصف المرشح، وثبات المسار القديم عند غياب المقاس.
|
| 30 |
+
|
| 31 |
+
## توحيد ترويسة H.264 مع الاستقبال المزدوج في v2.44
|
| 32 |
+
|
| 33 |
+
عند إعادة الترميز والنشر المتزامن إلى الاستقبالين الأساسي والاحتياطي عبر FFmpeg
|
| 34 |
+
`tee`، يضيف المرسِل الآن `-flags:v +global_header`. بذلك يقرأ كل مستقبل سجل
|
| 35 |
+
SPS/PPS نفسه منذ البداية بدل أن يلتقطا إعدادَي عرض مختلفين. الدليل الإنتاجي كان
|
| 36 |
+
`resolutionMismatch [error] Bad secondary stream` مع تغيّر حجم الصورة المعروضة،
|
| 37 |
+
وهو نمط يطابق حالة FFmpeg الموثقة #11527 لواجهة `tee` مع يوتيوب.
|
| 38 |
+
|
| 39 |
+
لا يغيّر الإصلاح المقاس أو فلاتر القص أو معدل البت، ولا يضيف الترويسة إلى مسار
|
| 40 |
+
`copy` الذي يحمل سجل المفكك الأصلي أصلًا. ويبقى النشر المفرد بلا تغيير. يغطي
|
| 41 |
+
`test_ingest_routes.py` وجود الترويسة مع الترميز المزدوج وعدم إضافتها إلى النسخ.
|
| 42 |
+
|
| 43 |
## تنظيف تلقائي عند غياب البث في v2.27
|
| 44 |
|
| 45 |
عند غياب أي بث بشكل كامل ومستمر لمدة `YOUTUBE_IDLE_RESET_SECONDS` (الافتراضي
|
ingest.py
CHANGED
|
@@ -6,7 +6,7 @@ import os, threading, time
|
|
| 6 |
from datetime import datetime
|
| 7 |
from zoneinfo import ZoneInfo
|
| 8 |
|
| 9 |
-
APP_VERSION = 'relay-v2.
|
| 10 |
|
| 11 |
|
| 12 |
def env_rtmp_destination():
|
|
|
|
| 6 |
from datetime import datetime
|
| 7 |
from zoneinfo import ZoneInfo
|
| 8 |
|
| 9 |
+
APP_VERSION = 'relay-v2.45-probed-canvas'
|
| 10 |
|
| 11 |
|
| 12 |
def env_rtmp_destination():
|
relay.py
CHANGED
|
@@ -469,8 +469,15 @@ class Sender:
|
|
| 469 |
return (self._gop_w, self._gop_h)
|
| 470 |
if candidate and not (w and h):
|
| 471 |
try:
|
| 472 |
-
|
| 473 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
except (TypeError, ValueError):
|
| 475 |
w = h = 0
|
| 476 |
try:
|
|
@@ -710,6 +717,12 @@ class Sender:
|
|
| 710 |
# يعني تسليمًا فعليًا، بلا مخزن يكذب على الحارس.
|
| 711 |
legs=self._dual_legs(destination,extra_destinations)
|
| 712 |
if legs:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 713 |
output=['-f','tee',self._tee_spec(legs,enhanced)]
|
| 714 |
else:
|
| 715 |
output=['-flvflags','no_duration_filesize','-f','flv']
|
|
@@ -1208,7 +1221,9 @@ class Sender:
|
|
| 1208 |
tele_meta={'candidate_label':candidate.get('label'),'candidate_brief':candidate.get('brief'),
|
| 1209 |
'media':candidate.get('media'),'codec':candidate.get('codec'),
|
| 1210 |
'quality_name':candidate.get('quality_name'),'width':candidate.get('width'),
|
| 1211 |
-
'height':candidate.get('height'),'
|
|
|
|
|
|
|
| 1212 |
if isinstance(self.telemetry_context,dict):
|
| 1213 |
tele_meta.update(self.telemetry_context)
|
| 1214 |
self.telemetry_key=telemetry_start(user,tele_meta)
|
|
|
|
| 469 |
return (self._gop_w, self._gop_h)
|
| 470 |
if candidate and not (w and h):
|
| 471 |
try:
|
| 472 |
+
# _select_source attaches dimensions parsed from the real FLV decoder record.
|
| 473 |
+
# Prefer them over TikTok's rendition label, which can remain at an earlier
|
| 474 |
+
# SD size while the served picture is already HD. Explicit w/h still wins.
|
| 475 |
+
pw,ph=int(candidate.get('probed_width') or 0),int(candidate.get('probed_height') or 0)
|
| 476 |
+
if pw>=32 and ph>=32:
|
| 477 |
+
w,h=pw,ph
|
| 478 |
+
else:
|
| 479 |
+
w = int(candidate.get('width') or 0)
|
| 480 |
+
h = int(candidate.get('height') or 0)
|
| 481 |
except (TypeError, ValueError):
|
| 482 |
w = h = 0
|
| 483 |
try:
|
|
|
|
| 717 |
# يعني تسليمًا فعليًا، بلا مخزن يكذب على الحارس.
|
| 718 |
legs=self._dual_legs(destination,extra_destinations)
|
| 719 |
if legs:
|
| 720 |
+
# Each tee leg opens its own FLV muxer. With x264, global extradata
|
| 721 |
+
# makes both YouTube ingests receive the same SPS/PPS from the start;
|
| 722 |
+
# without it YouTube can report resolutionMismatch / Bad secondary
|
| 723 |
+
# stream and change the displayed aspect ratio (FFmpeg ticket #11527).
|
| 724 |
+
# Copy already carries the bridge's decoder record and is unchanged.
|
| 725 |
+
if mode!='copy':video+=['-flags:v','+global_header']
|
| 726 |
output=['-f','tee',self._tee_spec(legs,enhanced)]
|
| 727 |
else:
|
| 728 |
output=['-flvflags','no_duration_filesize','-f','flv']
|
|
|
|
| 1221 |
tele_meta={'candidate_label':candidate.get('label'),'candidate_brief':candidate.get('brief'),
|
| 1222 |
'media':candidate.get('media'),'codec':candidate.get('codec'),
|
| 1223 |
'quality_name':candidate.get('quality_name'),'width':candidate.get('width'),
|
| 1224 |
+
'height':candidate.get('height'),'probed_width':candidate.get('probed_width'),
|
| 1225 |
+
'probed_height':candidate.get('probed_height'),
|
| 1226 |
+
'declared_bitrate_kbps':candidate.get('bitrate')}
|
| 1227 |
if isinstance(self.telemetry_context,dict):
|
| 1228 |
tele_meta.update(self.telemetry_context)
|
| 1229 |
self.telemetry_key=telemetry_start(user,tele_meta)
|
test_ingest_routes.py
CHANGED
|
@@ -229,6 +229,22 @@ class DualIngestTests(unittest.TestCase):
|
|
| 229 |
args=s.command(self.primary,{'codec':'h265'},extra_destinations=[self.backup])
|
| 230 |
self.assertEqual(' '.join(args).count('rtmp_enhanced_codecs=hvc1'),2)
|
| 231 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
def test_command_single_destination_is_unchanged(self):
|
| 233 |
s=Sender('/bin/true',lambda *a:None);s.mode_override='copy'
|
| 234 |
args=s.command(self.primary,{'codec':'h264'})
|
|
|
|
| 229 |
args=s.command(self.primary,{'codec':'h265'},extra_destinations=[self.backup])
|
| 230 |
self.assertEqual(' '.join(args).count('rtmp_enhanced_codecs=hvc1'),2)
|
| 231 |
|
| 232 |
+
def test_transcoded_tee_uses_global_video_header_on_both_flv_legs(self):
|
| 233 |
+
# Production evidence: YouTube resolutionMismatch / Bad secondary stream
|
| 234 |
+
# while one x264 sender fed primary+backup through tee. FFmpeg #11527
|
| 235 |
+
# documents the matching aspect-ratio symptom and the global-header fix.
|
| 236 |
+
s=Sender('/bin/true',lambda *a:None);s.mode_override='matchgop'
|
| 237 |
+
args=s.command(self.primary,{'codec':'h264','width':540,'height':960},
|
| 238 |
+
extra_destinations=[self.backup])
|
| 239 |
+
self.assertIn('tee',args)
|
| 240 |
+
self.assertEqual(args[args.index('-flags:v')+1],'+global_header')
|
| 241 |
+
|
| 242 |
+
def test_copy_tee_does_not_add_encoder_only_global_header_flag(self):
|
| 243 |
+
s=Sender('/bin/true',lambda *a:None);s.mode_override='copy'
|
| 244 |
+
args=s.command(self.primary,{'codec':'h264'},extra_destinations=[self.backup])
|
| 245 |
+
self.assertIn('tee',args)
|
| 246 |
+
self.assertNotIn('-flags:v',args)
|
| 247 |
+
|
| 248 |
def test_command_single_destination_is_unchanged(self):
|
| 249 |
s=Sender('/bin/true',lambda *a:None);s.mode_override='copy'
|
| 250 |
args=s.command(self.primary,{'codec':'h264'})
|
test_smart_mode.py
CHANGED
|
@@ -72,13 +72,15 @@ class CopyRecoveryTests(unittest.TestCase):
|
|
| 72 |
|
| 73 |
class CanvasCapTests(unittest.TestCase):
|
| 74 |
def test_matchgop_canvas_is_capped_at_720_class(self):
|
| 75 |
-
# 960x1920
|
| 76 |
-
#
|
|
|
|
| 77 |
sender = Sender('/bin/true', lambda *a: None)
|
| 78 |
sender.mode_override = 'matchgop'
|
| 79 |
args = sender.command('out.flv', {'codec': 'h265', 'width': 960, 'height': 1920})
|
| 80 |
vf = args[args.index('-vf') + 1]
|
| 81 |
-
self.assertIn('
|
|
|
|
| 82 |
self.assertNotIn('1920', vf)
|
| 83 |
|
| 84 |
def test_safe_size_mapping_still_applies(self):
|
|
|
|
| 72 |
|
| 73 |
class CanvasCapTests(unittest.TestCase):
|
| 74 |
def test_matchgop_canvas_is_capped_at_720_class(self):
|
| 75 |
+
# 960x1920 is capped to a 640x1280 canvas, never re-encoded at the
|
| 76 |
+
# full 1920 height. Since v2.43 this tall 1:2 picture fills that canvas
|
| 77 |
+
# and center-crops the excess height instead of adding pillarbox bars.
|
| 78 |
sender = Sender('/bin/true', lambda *a: None)
|
| 79 |
sender.mode_override = 'matchgop'
|
| 80 |
args = sender.command('out.flv', {'codec': 'h265', 'width': 960, 'height': 1920})
|
| 81 |
vf = args[args.index('-vf') + 1]
|
| 82 |
+
self.assertIn('crop=640:1280', vf)
|
| 83 |
+
self.assertNotIn('pad=640:1280', vf)
|
| 84 |
self.assertNotIn('1920', vf)
|
| 85 |
|
| 86 |
def test_safe_size_mapping_still_applies(self):
|
test_source_probe.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Deterministic FLV pre-start metadata tests; no network or FFmpeg required."""
|
| 3 |
+
import os
|
| 4 |
+
import pathlib
|
| 5 |
+
import sys
|
| 6 |
+
import tempfile
|
| 7 |
+
|
| 8 |
+
ROOT=pathlib.Path(sys.argv[1] if len(sys.argv)>1 else pathlib.Path(__file__).parent).resolve()
|
| 9 |
+
sys.path.insert(0,str(ROOT))
|
| 10 |
+
os.environ.setdefault('PASSWORD','test-pass-not-for-prod')
|
| 11 |
+
os.environ.setdefault('DATA_DIR',tempfile.mkdtemp(prefix='source-probe-test-'))
|
| 12 |
+
os.environ['YOUTUBE_FLV_PROBE_SECONDS']='0.2'
|
| 13 |
+
|
| 14 |
+
import youtube_manager as manager_module # noqa: E402
|
| 15 |
+
from relay import Sender # noqa: E402
|
| 16 |
+
from stream_monitor import video_dimensions # noqa: E402
|
| 17 |
+
|
| 18 |
+
passed=failed=0
|
| 19 |
+
def check(name,ok,detail=''):
|
| 20 |
+
global passed,failed
|
| 21 |
+
if ok:
|
| 22 |
+
passed+=1;print('PASS ',name)
|
| 23 |
+
else:
|
| 24 |
+
failed+=1;print('FAIL ',name,detail)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class Bits:
|
| 28 |
+
def __init__(self):self.data=[]
|
| 29 |
+
def bit(self,value):self.data.append(1 if value else 0)
|
| 30 |
+
def bits(self,value,count):
|
| 31 |
+
for shift in range(count-1,-1,-1):self.bit((value>>shift)&1)
|
| 32 |
+
def ue(self,value):
|
| 33 |
+
code=value+1;size=code.bit_length()
|
| 34 |
+
self.bits(0,size-1);self.bits(code,size)
|
| 35 |
+
def rbsp(self):
|
| 36 |
+
self.bit(1)
|
| 37 |
+
while len(self.data)%8:self.bit(0)
|
| 38 |
+
raw=bytes(sum(self.data[i+j]<<(7-j) for j in range(8)) for i in range(0,len(self.data),8))
|
| 39 |
+
out=bytearray();zeros=0
|
| 40 |
+
for byte in raw:
|
| 41 |
+
if zeros>=2 and byte<=3:
|
| 42 |
+
out.append(3);zeros=0
|
| 43 |
+
out.append(byte);zeros=zeros+1 if byte==0 else 0
|
| 44 |
+
return bytes(out)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def avcc(width,height):
|
| 48 |
+
"""Minimal baseline H.264 SPS/PPS with uncropped, frame-only 4:2:0 dimensions."""
|
| 49 |
+
assert width%16==0 and height%16==0
|
| 50 |
+
b=Bits();b.bits(66,8);b.bits(0,8);b.bits(30,8) # profile/constraints/level
|
| 51 |
+
b.ue(0);b.ue(0);b.ue(0);b.ue(0);b.ue(1);b.bit(0)
|
| 52 |
+
b.ue(width//16-1);b.ue(height//16-1)
|
| 53 |
+
b.bit(1);b.bit(1);b.bit(0);b.bit(0)
|
| 54 |
+
sps=b'\x67'+b.rbsp();pps=bytes.fromhex('68ce0fc8')
|
| 55 |
+
return (bytes([1,66,0,30,0xff,0xe1])+len(sps).to_bytes(2,'big')+sps+
|
| 56 |
+
b'\x01'+len(pps).to_bytes(2,'big')+pps)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def tag(config,ts=0):
|
| 60 |
+
body=b'\x17\x00\x00\x00\x00'+config;size=len(body)
|
| 61 |
+
header=(b'\x09'+size.to_bytes(3,'big')+bytes([
|
| 62 |
+
(ts>>16)&255,(ts>>8)&255,ts&255,(ts>>24)&255,0,0,0]))
|
| 63 |
+
return header+body+(11+size).to_bytes(4,'big')
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def flv(*tags):
|
| 67 |
+
return b'FLV\x01\x05\x00\x00\x00\x09\x00\x00\x00\x00'+b''.join(tags)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class Response:
|
| 71 |
+
def __init__(self,chunks):self.chunks=list(chunks);self.closed=False
|
| 72 |
+
def raise_for_status(self):return None
|
| 73 |
+
def iter_content(self):yield from self.chunks
|
| 74 |
+
def close(self):self.closed=True
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def manager():
|
| 78 |
+
obj=manager_module.ManagedYouTube.__new__(manager_module.ManagedYouTube)
|
| 79 |
+
obj.headers_for=lambda user:{}
|
| 80 |
+
obj._unstable={};obj.starve={};obj._skip_probe_once={};obj._probe_note=''
|
| 81 |
+
obj.logs=[];obj.log=lambda user,text:obj.logs.append(str(text))
|
| 82 |
+
return obj
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def candidate(width=432,height=864):
|
| 86 |
+
return {'url':'https://media.example.test/live.flv','media':'flv','codec':'h264',
|
| 87 |
+
'quality_name':'hd','label':'hd','brief':'hd H264','width':width,'height':height}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def probe_with(chunks,cand=None):
|
| 91 |
+
obj=manager();responses=[]
|
| 92 |
+
original=manager_module.requests.get
|
| 93 |
+
def fake_get(*args,**kwargs):
|
| 94 |
+
response=Response(chunks);responses.append(response);return response
|
| 95 |
+
manager_module.requests.get=fake_get
|
| 96 |
+
try:result=obj.probe('alice',cand or candidate())
|
| 97 |
+
finally:manager_module.requests.get=original
|
| 98 |
+
return result,responses
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
small=avcc(16,16);real=avcc(640,1280)
|
| 102 |
+
check('test SPS generator carries exact dimensions',
|
| 103 |
+
video_dimensions('avc1',small)==(16,16) and video_dimensions('avc1',real)==(640,1280))
|
| 104 |
+
|
| 105 |
+
blob=flv(tag(real));chunks=[blob[i:i+7] for i in range(0,len(blob),7)]
|
| 106 |
+
result,responses=probe_with(chunks)
|
| 107 |
+
check('fragmented FLV probe returns actual decoder dimensions',
|
| 108 |
+
isinstance(result,dict) and (result.get('width'),result.get('height'))==(640,1280),str(result))
|
| 109 |
+
check('FLV probe closes the source response',bool(responses) and responses[0].closed)
|
| 110 |
+
|
| 111 |
+
blob=flv(tag(small),tag(real));split=13+len(tag(small))
|
| 112 |
+
result,_=probe_with([blob[:split],blob[split:]])
|
| 113 |
+
check('sub-32px startup config is not accepted as the final canvas',
|
| 114 |
+
isinstance(result,dict) and (result.get('width'),result.get('height'))==(640,1280),str(result))
|
| 115 |
+
|
| 116 |
+
result,responses=probe_with([b'FLV'])
|
| 117 |
+
check('signature-only FLV keeps the old truthy fallback',result is True,str(result))
|
| 118 |
+
check('signature-only fallback also closes the response',bool(responses) and responses[0].closed)
|
| 119 |
+
result,_=probe_with([b'NOT AN FLV'])
|
| 120 |
+
check('invalid FLV signature is still rejected',result is False,str(result))
|
| 121 |
+
|
| 122 |
+
obj=manager();obj.probe=lambda user,c:{'width':640,'height':1280,'codec':'avc1'}
|
| 123 |
+
selected=obj._select_source('alice',{'sources':[candidate()]})
|
| 124 |
+
check('source selection retains a usable candidate',len(selected)==1,str(selected))
|
| 125 |
+
if selected:
|
| 126 |
+
chosen=selected[0]
|
| 127 |
+
check('actual dimensions are attached without rewriting the rendition label',
|
| 128 |
+
(chosen.get('width'),chosen.get('height'))==(432,864) and
|
| 129 |
+
(chosen.get('probed_width'),chosen.get('probed_height'))==(640,1280) and
|
| 130 |
+
obj._source_pin(chosen)==obj._source_pin(candidate()),str(chosen))
|
| 131 |
+
sender=Sender('ffmpeg',lambda *args:None)
|
| 132 |
+
check('matchgop locks the actual HD canvas instead of the stale SD label',
|
| 133 |
+
sender.lock_gold(chosen)==(720,1280),str(sender.lock_gold(chosen)))
|
| 134 |
+
check('a declared/probed mismatch is surfaced in operational logs',
|
| 135 |
+
any('640x1280' in line and '432x864' in line for line in obj.logs),str(obj.logs))
|
| 136 |
+
|
| 137 |
+
fallback=Sender('ffmpeg',lambda *args:None)
|
| 138 |
+
check('without decoder metadata the old declared-canvas fallback is unchanged',
|
| 139 |
+
fallback.lock_gold(candidate())==(432,864),str(fallback.lock_gold(candidate())))
|
| 140 |
+
|
| 141 |
+
print('source probe tests: %d passed, %d failed'%(passed,failed))
|
| 142 |
+
raise SystemExit(1 if failed else 0)
|
verify.sh
CHANGED
|
@@ -30,6 +30,7 @@ python3 test_source_reconnect.py "$ROOT"
|
|
| 30 |
python3 test_ingest_routes.py "$ROOT"
|
| 31 |
python3 test_transition_ingest.py "$ROOT"
|
| 32 |
python3 test_flv_bridge.py "$ROOT"
|
|
|
|
| 33 |
python3 test_adaptive_format.py "$ROOT"
|
| 34 |
python3 test_completion.py "$ROOT"
|
| 35 |
python3 test_telemetry.py "$ROOT"
|
|
|
|
| 30 |
python3 test_ingest_routes.py "$ROOT"
|
| 31 |
python3 test_transition_ingest.py "$ROOT"
|
| 32 |
python3 test_flv_bridge.py "$ROOT"
|
| 33 |
+
python3 test_source_probe.py "$ROOT"
|
| 34 |
python3 test_adaptive_format.py "$ROOT"
|
| 35 |
python3 test_completion.py "$ROOT"
|
| 36 |
python3 test_telemetry.py "$ROOT"
|
youtube_manager.py
CHANGED
|
@@ -8,6 +8,7 @@ from zoneinfo import ZoneInfo
|
|
| 8 |
from urllib.parse import urlparse, urlunparse, quote
|
| 9 |
from curl_cffi import requests
|
| 10 |
from relay import RelayStartError, Sender, SourceStalled, source_http_headers
|
|
|
|
| 11 |
from stream_monitor import log_event
|
| 12 |
from telemetry import record_event as telemetry_event
|
| 13 |
import inspect
|
|
@@ -1552,11 +1553,16 @@ class ManagedYouTube:
|
|
| 1552 |
def _source_magic(media):
|
| 1553 |
return b'#EXTM3U' if media=='hls' else b'FLV'
|
| 1554 |
def probe(self,user,candidate):
|
| 1555 |
-
"""
|
| 1556 |
|
| 1557 |
-
An FLV rendition must start with the 'FLV' signature, an HLS one with '#EXTM3U'
|
| 1558 |
-
|
| 1559 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1560 |
"""
|
| 1561 |
media=(candidate or {}).get('media') or 'flv'
|
| 1562 |
want=self._source_magic(media);got=bytearray()
|
|
@@ -1564,10 +1570,36 @@ class ManagedYouTube:
|
|
| 1564 |
headers=source_http_headers(candidate.get('url'),user,self.headers_for),stream=True,timeout=20)
|
| 1565 |
try:
|
| 1566 |
r.raise_for_status()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1567 |
for chunk in r.iter_content():
|
| 1568 |
-
|
| 1569 |
-
|
| 1570 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1571 |
finally:r.close()
|
| 1572 |
def codec_probe(self,candidate):
|
| 1573 |
"""ffprobe the real payload; TikTok's labels describe the block, not what is served now.
|
|
@@ -1742,7 +1774,21 @@ class ManagedYouTube:
|
|
| 1742 |
for c in ordered:
|
| 1743 |
tagged={**c,'user':user}
|
| 1744 |
try:
|
| 1745 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1746 |
usable.append(tagged)
|
| 1747 |
except Exception as e:
|
| 1748 |
self._probe_note=self.safe_error(e)
|
|
|
|
| 8 |
from urllib.parse import urlparse, urlunparse, quote
|
| 9 |
from curl_cffi import requests
|
| 10 |
from relay import RelayStartError, Sender, SourceStalled, source_http_headers
|
| 11 |
+
from flv_bridge import FLVBridge
|
| 12 |
from stream_monitor import log_event
|
| 13 |
from telemetry import record_event as telemetry_event
|
| 14 |
import inspect
|
|
|
|
| 1553 |
def _source_magic(media):
|
| 1554 |
return b'#EXTM3U' if media=='hls' else b'FLV'
|
| 1555 |
def probe(self,user,candidate):
|
| 1556 |
+
"""Validate the payload and, for FLV, read its real decoder canvas when readily available.
|
| 1557 |
|
| 1558 |
+
An FLV rendition must start with the 'FLV' signature, an HLS one with '#EXTM3U'.
|
| 1559 |
+
TikTok's rendition label can describe a different size from the AVC/HEVC configuration
|
| 1560 |
+
actually served. The existing pre-start HTTP probe already opens the source, so parse its
|
| 1561 |
+
first decoder record here instead of locking ``matchgop`` to that unreliable label.
|
| 1562 |
+
|
| 1563 |
+
The return remains truthy/falsy for existing callers. A mapping with ``width``, ``height``
|
| 1564 |
+
and ``codec`` is returned when a real (>=32px) configuration arrives within the bounded
|
| 1565 |
+
prefix; plain True preserves the old signature-only behavior when it does not.
|
| 1566 |
"""
|
| 1567 |
media=(candidate or {}).get('media') or 'flv'
|
| 1568 |
want=self._source_magic(media);got=bytearray()
|
|
|
|
| 1570 |
headers=source_http_headers(candidate.get('url'),user,self.headers_for),stream=True,timeout=20)
|
| 1571 |
try:
|
| 1572 |
r.raise_for_status()
|
| 1573 |
+
if media=='hls':
|
| 1574 |
+
for chunk in r.iter_content():
|
| 1575 |
+
got+=chunk
|
| 1576 |
+
if len(got)>=len(want):return bytes(got).startswith(b'#EXTM3U')
|
| 1577 |
+
return False
|
| 1578 |
+
|
| 1579 |
+
try:max_bytes=max(4096,int(os.getenv('YOUTUBE_FLV_PROBE_BYTES','262144')))
|
| 1580 |
+
except (TypeError,ValueError):max_bytes=262144
|
| 1581 |
+
try:probe_seconds=max(0.2,float(os.getenv('YOUTUBE_FLV_PROBE_SECONDS','4')))
|
| 1582 |
+
except (TypeError,ValueError):probe_seconds=4.0
|
| 1583 |
+
# Observational parser only: probing must not duplicate STREAM_CONFIG or bitrate
|
| 1584 |
+
# events in the persistent operational log. The real sender has its own bridge.
|
| 1585 |
+
bridge=FLVBridge(user=user,event_logger=lambda *args:None)
|
| 1586 |
+
deadline=time.monotonic()+probe_seconds;seen=0
|
| 1587 |
for chunk in r.iter_content():
|
| 1588 |
+
if not chunk:continue
|
| 1589 |
+
seen+=len(chunk)
|
| 1590 |
+
if len(got)<len(want):got+=chunk[:len(want)-len(got)]
|
| 1591 |
+
if len(got)>=len(want) and bytes(got[:len(want)])!=want:return False
|
| 1592 |
+
try:bridge.feed(chunk)
|
| 1593 |
+
except (ValueError,IndexError):
|
| 1594 |
+
# A valid signature with a truncated prefix retains the old fallback: the
|
| 1595 |
+
# sender will try the candidate and its watchdog will classify any failure.
|
| 1596 |
+
return bytes(got[:len(want)])==want
|
| 1597 |
+
size=bridge.video_size
|
| 1598 |
+
if size and min(size)>=32:
|
| 1599 |
+
return {'width':int(size[0]),'height':int(size[1]),
|
| 1600 |
+
'codec':str(bridge.video_codec or '')}
|
| 1601 |
+
if seen>=max_bytes or time.monotonic()>=deadline:break
|
| 1602 |
+
return len(got)>=len(want) and bytes(got[:len(want)])==want
|
| 1603 |
finally:r.close()
|
| 1604 |
def codec_probe(self,candidate):
|
| 1605 |
"""ffprobe the real payload; TikTok's labels describe the block, not what is served now.
|
|
|
|
| 1774 |
for c in ordered:
|
| 1775 |
tagged={**c,'user':user}
|
| 1776 |
try:
|
| 1777 |
+
probed=self.probe(user,c)
|
| 1778 |
+
if probed:
|
| 1779 |
+
if isinstance(probed,dict):
|
| 1780 |
+
try:w,h=int(probed.get('width') or 0),int(probed.get('height') or 0)
|
| 1781 |
+
except (TypeError,ValueError):w=h=0
|
| 1782 |
+
if w>=32 and h>=32:
|
| 1783 |
+
# Keep the rendition label/pin unchanged; these fields describe the
|
| 1784 |
+
# actual decoder record and are used only for the constant canvas.
|
| 1785 |
+
tagged['probed_width'],tagged['probed_height']=w,h
|
| 1786 |
+
tagged['probed_codec']=str(probed.get('codec') or '')
|
| 1787 |
+
try:declared=(int(c.get('width') or 0),int(c.get('height') or 0))
|
| 1788 |
+
except (TypeError,ValueError):declared=(0,0)
|
| 1789 |
+
if declared!=(w,h):
|
| 1790 |
+
self.log(user,'[SOURCE] FLV config %sx%s يخالف وصف المرشح %sx%s؛ سيُثبَّت الخرج على المقاس الحقيقي'%(
|
| 1791 |
+
w,h,declared[0],declared[1]))
|
| 1792 |
usable.append(tagged)
|
| 1793 |
except Exception as e:
|
| 1794 |
self._probe_note=self.safe_error(e)
|