Revert assistant HD changes; restore original relay behavior
Browse files- Dockerfile +1 -5
- README.md +0 -21
- app.py +1 -3
- index.html +0 -1
- relay.py +0 -14
- test_quality_profile.py +0 -36
- verify.sh +0 -1
- youtube_manager.py +1 -15
Dockerfile
CHANGED
|
@@ -6,7 +6,7 @@ WORKDIR /app
|
|
| 6 |
COPY requirements.txt .
|
| 7 |
RUN pip install --no-cache-dir -U -r requirements.txt
|
| 8 |
COPY app.py relay.py youtube_api.py youtube_manager.py flv_bridge.py stream_monitor.py tiktok_auth.py ingest.py index.html ./
|
| 9 |
-
COPY verify.sh verify_tokens.py probe_live.py test_watchdog.py test_lifecycle.py test_relay.py test_source_reconnect.py test_ingest_routes.py test_transition_ingest.py test_concurrency.py test_flv_bridge.py test_adaptive_format.py test_regressions.py test_completion.py test_channel_switch.py test_dashboard.js
|
| 10 |
|
| 11 |
# TikTok Live Monitor → Direct Relay → YouTube. No local video files.
|
| 12 |
ENV TZ=Asia/Baghdad \
|
|
@@ -62,9 +62,5 @@ ENV TZ=Asia/Baghdad \
|
|
| 62 |
PYTHONUNBUFFERED=1 \
|
| 63 |
YOUTUBE_VIDEO_MODE=original
|
| 64 |
|
| 65 |
-
# Keep this broadcaster's output HD even when TikTok starts with an SD rendition.
|
| 66 |
-
# Override with {} in Space variables to restore the legacy per-group policy.
|
| 67 |
-
ENV YOUTUBE_ACCOUNT_SEND_MODES='{"muhajer_iraqi":"fixed720"}'
|
| 68 |
-
|
| 69 |
EXPOSE 7860
|
| 70 |
CMD ["sh", "-c", "exec uvicorn app:app --host 0.0.0.0 --port ${PORT:-7860} --timeout-graceful-shutdown 65 --no-access-log"]
|
|
|
|
| 6 |
COPY requirements.txt .
|
| 7 |
RUN pip install --no-cache-dir -U -r requirements.txt
|
| 8 |
COPY app.py relay.py youtube_api.py youtube_manager.py flv_bridge.py stream_monitor.py tiktok_auth.py ingest.py index.html ./
|
| 9 |
+
COPY verify.sh verify_tokens.py probe_live.py test_watchdog.py test_lifecycle.py test_relay.py test_source_reconnect.py test_ingest_routes.py test_transition_ingest.py test_concurrency.py test_flv_bridge.py test_adaptive_format.py test_regressions.py test_completion.py test_channel_switch.py test_dashboard.js ./
|
| 10 |
|
| 11 |
# TikTok Live Monitor → Direct Relay → YouTube. No local video files.
|
| 12 |
ENV TZ=Asia/Baghdad \
|
|
|
|
| 62 |
PYTHONUNBUFFERED=1 \
|
| 63 |
YOUTUBE_VIDEO_MODE=original
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
EXPOSE 7860
|
| 66 |
CMD ["sh", "-c", "exec uvicorn app:app --host 0.0.0.0 --port ${PORT:-7860} --timeout-graceful-shutdown 65 --no-access-log"]
|
README.md
CHANGED
|
@@ -112,24 +112,3 @@ PASSWORD=test DATA_DIR="$(mktemp -d)" sh verify.sh
|
|
| 112 |
This application uses YouTube API Services to allow authenticated users to broadcast live content to their own YouTube channels.
|
| 113 |
- By using this app, users agree to be bound by the [YouTube Terms of Service](https://www.youtube.com/t/terms) and [Google Privacy Policy](https://www.google.com/policies/privacy).
|
| 114 |
- We do not store or share personal user data with third parties.
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
## Per-account HD output (2026-10-10)
|
| 118 |
-
|
| 119 |
-
`YOUTUBE_ACCOUNT_SEND_MODES` is an optional JSON object mapping usernames to
|
| 120 |
-
`matchgop`, `fixed720`, or `fixed1080`. It takes precedence over the legacy
|
| 121 |
-
`YOUTUBE_FORCE_TRANSCODE_USERS` group for listed accounts only, and uses the same
|
| 122 |
-
encoder-slot limits. Invalid optional settings fall back to the legacy policy.
|
| 123 |
-
|
| 124 |
-
The image sets `{"muhajer_iraqi":"fixed720"}`: 720×1280 H.264, 4000 kbit/s unless
|
| 125 |
-
`VIDEO_BITRATE` overrides it. This avoids retaining a 360×640 matchgop canvas when
|
| 126 |
-
TikTok later supplies HD. It does not create detail missing from an SD source.
|
| 127 |
-
Other accounts retain their existing policy. Set the Space variable
|
| 128 |
-
`YOUTUBE_ACCOUNT_SEND_MODES` to `{}` to restore the legacy group policy (restart
|
| 129 |
-
required). The dashboard and `[QUALITY]` log distinguish encoder output from
|
| 130 |
-
source metadata; these are not measurements of YouTube's playback renditions.
|
| 131 |
-
|
| 132 |
-
Checks: `python test_quality_profile.py` covers override isolation, invalid config,
|
| 133 |
-
persisted SD canvas, and unchanged matchgop locking. A local synthetic 1080×1920
|
| 134 |
-
FFmpeg input was also encoded successfully as 720×1280 H.264 at a configured
|
| 135 |
-
4000 kbit/s. Actual YouTube availability still requires a live playback check.
|
|
|
|
| 112 |
This application uses YouTube API Services to allow authenticated users to broadcast live content to their own YouTube channels.
|
| 113 |
- By using this app, users agree to be bound by the [YouTube Terms of Service](https://www.youtube.com/t/terms) and [Google Privacy Policy](https://www.google.com/policies/privacy).
|
| 114 |
- We do not store or share personal user data with third parties.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.py
CHANGED
|
@@ -356,7 +356,7 @@ def source_brief(item):
|
|
| 356 |
nums=[int(n) for n in re.findall(r'\d+',res)]
|
| 357 |
pretty=''
|
| 358 |
if len(nums)>=2:
|
| 359 |
-
h=
|
| 360 |
pretty='%dp'%h if h>=144 else '%sx%s'%(nums[0],nums[1])
|
| 361 |
elif nums:
|
| 362 |
pretty='%dp'%nums[0]
|
|
@@ -970,8 +970,6 @@ def state():
|
|
| 970 |
'last_data_time':rs.get('last_data_time'),'relay_alive':rs.get('relay_alive'),
|
| 971 |
'session_title':rs.get('session_title') or '','restarts':rs.get('restarts') or 0,
|
| 972 |
'source_age':rs.get('source_age'),'send_mode':rs.get('send_mode') or '',
|
| 973 |
-
'output_resolution':rs.get('output_resolution') or '',
|
| 974 |
-
'output_video_bitrate':rs.get('output_video_bitrate') or '',
|
| 975 |
'youtube_health':str(ye.get('youtube_health') or ''),
|
| 976 |
'youtube_issues':str(ye.get('youtube_issues') or '')[:120],
|
| 977 |
'youtube_status':str(ye.get('youtube_status') or ('relay' if rs.get('relay_alive') else (t.get('state') or '')))})
|
|
|
|
| 356 |
nums=[int(n) for n in re.findall(r'\d+',res)]
|
| 357 |
pretty=''
|
| 358 |
if len(nums)>=2:
|
| 359 |
+
h=max(nums[0],nums[1])
|
| 360 |
pretty='%dp'%h if h>=144 else '%sx%s'%(nums[0],nums[1])
|
| 361 |
elif nums:
|
| 362 |
pretty='%dp'%nums[0]
|
|
|
|
| 970 |
'last_data_time':rs.get('last_data_time'),'relay_alive':rs.get('relay_alive'),
|
| 971 |
'session_title':rs.get('session_title') or '','restarts':rs.get('restarts') or 0,
|
| 972 |
'source_age':rs.get('source_age'),'send_mode':rs.get('send_mode') or '',
|
|
|
|
|
|
|
| 973 |
'youtube_health':str(ye.get('youtube_health') or ''),
|
| 974 |
'youtube_issues':str(ye.get('youtube_issues') or '')[:120],
|
| 975 |
'youtube_status':str(ye.get('youtube_status') or ('relay' if rs.get('relay_alive') else (t.get('state') or '')))})
|
index.html
CHANGED
|
@@ -224,7 +224,6 @@ async function refresh(){
|
|
| 224 |
<p>@${esc(e.username)} · ${esc(e.phase||'')} · YT ${esc(e.youtube_status||'—')}</p>
|
| 225 |
<p>المصدر: ${esc(e.source||'—')}</p>
|
| 226 |
<p>وضع الإرسال الفعلي: ${esc(e.send_mode||'قيد التحديد')}</p>
|
| 227 |
-
<p>خرج المرسل إلى يوتيوب: ${esc(e.output_resolution||'غير معروف')} · ${esc(e.output_video_bitrate||'—')}</p>
|
| 228 |
${e.pending_send_mode?`<p>الترميز الثابت ينتظر خانة: ${esc(e.pending_send_mode)}</p>`:''}
|
| 229 |
<small>${esc(e.status||e.note||'')} · ${Math.floor(e.output_seconds||0)} ث</small>
|
| 230 |
${e.broadcast_id?`<p><a target="_blank" rel="noopener" href="https://www.youtube.com/watch?v=${encodeURIComponent(e.broadcast_id)}">فتح على يوتيوب</a></p>`:''}
|
|
|
|
| 224 |
<p>@${esc(e.username)} · ${esc(e.phase||'')} · YT ${esc(e.youtube_status||'—')}</p>
|
| 225 |
<p>المصدر: ${esc(e.source||'—')}</p>
|
| 226 |
<p>وضع الإرسال الفعلي: ${esc(e.send_mode||'قيد التحديد')}</p>
|
|
|
|
| 227 |
${e.pending_send_mode?`<p>الترميز الثابت ينتظر خانة: ${esc(e.pending_send_mode)}</p>`:''}
|
| 228 |
<small>${esc(e.status||e.note||'')} · ${Math.floor(e.output_seconds||0)} ث</small>
|
| 229 |
${e.broadcast_id?`<p><a target="_blank" rel="noopener" href="https://www.youtube.com/watch?v=${encodeURIComponent(e.broadcast_id)}">فتح على يوتيوب</a></p>`:''}
|
relay.py
CHANGED
|
@@ -564,16 +564,6 @@ class Sender:
|
|
| 564 |
'-x264-params','nal-hrd=cbr:force-cfr=1:open-gop=0:scenecut=0:keyint=%s:min-keyint=30'%os.getenv('YOUTUBE_GOP','60'),
|
| 565 |
'-pix_fmt','yuv420p','-fps_mode','cfr',
|
| 566 |
'-colorspace','bt709','-color_primaries','bt709','-color_trc','bt709']
|
| 567 |
-
# Distinguish the source label from the actual encoder canvas in diagnostics.
|
| 568 |
-
if mode=='copy':
|
| 569 |
-
output_w=int((candidate or {}).get('width') or 0)
|
| 570 |
-
output_h=int((candidate or {}).get('height') or 0)
|
| 571 |
-
elif mode=='matchgop':
|
| 572 |
-
output_w,output_h=self._gop_w,self._gop_h
|
| 573 |
-
else:
|
| 574 |
-
output_w,output_h=width,height
|
| 575 |
-
self.set(output_resolution=('%sx%s'%(output_w,output_h)) if output_w and output_h else '',
|
| 576 |
-
output_video_bitrate=bitrate if mode!='copy' else 'source')
|
| 577 |
# Network output goes through the fifo pseudo-muxer instead of straight to flv.
|
| 578 |
# fifo separates encoding from the RTMPS connection and reconnects transparently
|
| 579 |
# when YouTube drops it, rather than killing the whole FFmpeg process. That is
|
|
@@ -1032,10 +1022,6 @@ class Sender:
|
|
| 1032 |
'إعلان Enhanced-RTMP: hvc1' if '-rtmp_enhanced_codecs' in ' '.join(args)
|
| 1033 |
else 'FLV تقليدي: لا إعلان hvc1', self.active_mode or 'unknown')) # لا إعادة ترميز في هذا الوضع: انقطاع copy لا يترك عملية ورائها
|
| 1034 |
|
| 1035 |
-
self.log(user, '[QUALITY] output=%s bitrate=%s mode=%s' % (
|
| 1036 |
-
self.data.get('output_resolution') or 'unknown',
|
| 1037 |
-
self.data.get('output_video_bitrate') or 'unknown', self.active_mode))
|
| 1038 |
-
|
| 1039 |
def read_errors():
|
| 1040 |
# يصنّف أخطاء FFmpeg المختارة؛ لا يُسرّب stderr خامًا ولا روابط البث — الذيل
|
| 1041 |
# يُخفى مفتاحه ورابطه قبل أي تخزين أو طباعة.
|
|
|
|
| 564 |
'-x264-params','nal-hrd=cbr:force-cfr=1:open-gop=0:scenecut=0:keyint=%s:min-keyint=30'%os.getenv('YOUTUBE_GOP','60'),
|
| 565 |
'-pix_fmt','yuv420p','-fps_mode','cfr',
|
| 566 |
'-colorspace','bt709','-color_primaries','bt709','-color_trc','bt709']
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 567 |
# Network output goes through the fifo pseudo-muxer instead of straight to flv.
|
| 568 |
# fifo separates encoding from the RTMPS connection and reconnects transparently
|
| 569 |
# when YouTube drops it, rather than killing the whole FFmpeg process. That is
|
|
|
|
| 1022 |
'إعلان Enhanced-RTMP: hvc1' if '-rtmp_enhanced_codecs' in ' '.join(args)
|
| 1023 |
else 'FLV تقليدي: لا إعلان hvc1', self.active_mode or 'unknown')) # لا إعادة ترميز في هذا الوضع: انقطاع copy لا يترك عملية ورائها
|
| 1024 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1025 |
def read_errors():
|
| 1026 |
# يصنّف أخطاء FFmpeg المختارة؛ لا يُسرّب stderr خامًا ولا روابط البث — الذيل
|
| 1027 |
# يُخفى مفتاحه ورابطه قبل أي تخزين أو طباعة.
|
test_quality_profile.py
DELETED
|
@@ -1,36 +0,0 @@
|
|
| 1 |
-
"""HD output must not inherit a persisted SD matchgop canvas."""
|
| 2 |
-
import os, unittest
|
| 3 |
-
from unittest.mock import patch
|
| 4 |
-
from relay import Sender
|
| 5 |
-
from youtube_manager import ManagedYouTube
|
| 6 |
-
|
| 7 |
-
class QualityProfileTests(unittest.TestCase):
|
| 8 |
-
def test_specific_profile_does_not_change_other_accounts(self):
|
| 9 |
-
with patch.dict(os.environ, {'YOUTUBE_ACCOUNT_SEND_MODES':'{"muhajer_iraqi":"fixed720"}',
|
| 10 |
-
'YOUTUBE_FORCE_TRANSCODE_USERS':'muhajer_iraqi,other',
|
| 11 |
-
'YOUTUBE_FORCE_TRANSCODE_MODE':'matchgop'}):
|
| 12 |
-
self.assertEqual(ManagedYouTube.forced_mode('@MUHAJER_IRAQI'),'fixed720')
|
| 13 |
-
self.assertEqual(ManagedYouTube.forced_mode('other'),'matchgop')
|
| 14 |
-
self.assertEqual(ManagedYouTube.forced_mode('unlisted'),'')
|
| 15 |
-
def test_invalid_configuration_retains_legacy_policy(self):
|
| 16 |
-
for value in ('bad', '[]', '{"other":"copy"}'):
|
| 17 |
-
with patch.dict(os.environ, {'YOUTUBE_ACCOUNT_SEND_MODES':value,
|
| 18 |
-
'YOUTUBE_FORCE_TRANSCODE_USERS':'other','YOUTUBE_FORCE_TRANSCODE_MODE':'matchgop'}):
|
| 19 |
-
self.assertEqual(ManagedYouTube.forced_mode('other'),'matchgop')
|
| 20 |
-
def test_fixed720_ignores_old_sd_canvas_for_all_sources(self):
|
| 21 |
-
with patch.dict(os.environ, {'VIDEO_BITRATE':'','YOUTUBE_X264_PRESET':'ultrafast'}):
|
| 22 |
-
s=Sender('/bin/true',lambda *a:None)
|
| 23 |
-
s.sticky_mode='fixed720';s._no_copy_recovery=True
|
| 24 |
-
s._gop_w,s._gop_h=360,640
|
| 25 |
-
for w,h in ((360,640),(720,1280),(1080,1920)):
|
| 26 |
-
c={'width':w,'height':h,'codec':'h264','media':'flv'}
|
| 27 |
-
cmd=s.command('/tmp/test.flv',c)
|
| 28 |
-
self.assertIn('scale=w=720:h=1280',cmd[cmd.index('-vf')+1])
|
| 29 |
-
self.assertEqual(cmd[cmd.index('-b:v')+1],'4000k')
|
| 30 |
-
self.assertEqual(s.snapshot()['output_resolution'],'720x1280')
|
| 31 |
-
def test_unlisted_matchgop_still_keeps_canvas(self):
|
| 32 |
-
s=Sender('/bin/true',lambda *a:None)
|
| 33 |
-
s.lock_gold({'width':360,'height':640})
|
| 34 |
-
self.assertEqual(s.lock_gold({'width':1080,'height':1920}),(360,640))
|
| 35 |
-
|
| 36 |
-
if __name__=='__main__':unittest.main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
verify.sh
CHANGED
|
@@ -17,7 +17,6 @@ assert '/api/download' not in (root/'app.py').read_text()
|
|
| 17 |
print('PASS no recorder / no video download API')
|
| 18 |
PY
|
| 19 |
|
| 20 |
-
python3 test_quality_profile.py
|
| 21 |
python3 test_concurrency.py "$ROOT"
|
| 22 |
python3 test_channel_switch.py "$ROOT"
|
| 23 |
if command -v node >/dev/null 2>&1; then
|
|
|
|
| 17 |
print('PASS no recorder / no video download API')
|
| 18 |
PY
|
| 19 |
|
|
|
|
| 20 |
python3 test_concurrency.py "$ROOT"
|
| 21 |
python3 test_channel_switch.py "$ROOT"
|
| 22 |
if command -v node >/dev/null 2>&1; then
|
youtube_manager.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
Each event carries the name/title/hashtags of the TikTok account it relays, and each
|
| 3 |
account gets its own reusable ingestion stream so concurrent events never mix.
|
| 4 |
Fail closed on ambiguous remote creates."""
|
| 5 |
-
import os, re, subprocess, threading, time, uuid
|
| 6 |
from datetime import datetime, timezone, timedelta
|
| 7 |
from zoneinfo import ZoneInfo
|
| 8 |
from urllib.parse import urlparse, urlunparse, quote
|
|
@@ -142,18 +142,6 @@ class ManagedYouTube:
|
|
| 142 |
@staticmethod
|
| 143 |
def forced_mode(user):
|
| 144 |
"""Optional per-account reliability mode; never forces every relay to burn CPU."""
|
| 145 |
-
# Per-account overrides precede the legacy group mode. This lets one SD-pinned
|
| 146 |
-
# event use a fixed HD canvas without changing other running broadcasters.
|
| 147 |
-
name=str(user or '').strip().lower().lstrip('@')
|
| 148 |
-
try:
|
| 149 |
-
profiles=json.loads(os.getenv('YOUTUBE_ACCOUNT_SEND_MODES','{}'))
|
| 150 |
-
if isinstance(profiles,dict):
|
| 151 |
-
for account,mode in profiles.items():
|
| 152 |
-
if str(account).strip().lower().lstrip('@') == name:
|
| 153 |
-
if mode in ('matchgop','fixed720','fixed1080'):
|
| 154 |
-
return mode
|
| 155 |
-
except (ValueError,TypeError):
|
| 156 |
-
pass # Invalid optional config must not disable the legacy safety policy.
|
| 157 |
users={x.strip().lower().lstrip('@') for x in
|
| 158 |
os.getenv('YOUTUBE_FORCE_TRANSCODE_USERS','').split(',') if x.strip()}
|
| 159 |
if str(user or '').lower().lstrip('@') not in users:
|
|
@@ -430,8 +418,6 @@ class ManagedYouTube:
|
|
| 430 |
'status':s.get('status',''),'source':s.get('source',''),'output_seconds':s.get('output_seconds',0),
|
| 431 |
'send_mode':s.get('send_mode') or ev.get('sender_mode',''),
|
| 432 |
'pending_send_mode':s.get('pending_send_mode') or ev.get('sender_pending_mode',''),
|
| 433 |
-
'output_resolution':s.get('output_resolution') or '',
|
| 434 |
-
'output_video_bitrate':s.get('output_video_bitrate') or '',
|
| 435 |
# عمر آخر بايت من المصدر + حدّ الحارس: اللوحة تُظهر «المصدر صامت 12 ث» بدل أن
|
| 436 |
# يظن المشاهد أن البث ميت بينما الجلسة تعمل (ما حدث في تجمّد @viggoo313).
|
| 437 |
# صمت المصدر لحظة آخر قفل: يشرح في اللوحة سبب 187 (جوع تغذية لا رفض مستقبِل).
|
|
|
|
| 2 |
Each event carries the name/title/hashtags of the TikTok account it relays, and each
|
| 3 |
account gets its own reusable ingestion stream so concurrent events never mix.
|
| 4 |
Fail closed on ambiguous remote creates."""
|
| 5 |
+
import os, re, subprocess, threading, time, uuid
|
| 6 |
from datetime import datetime, timezone, timedelta
|
| 7 |
from zoneinfo import ZoneInfo
|
| 8 |
from urllib.parse import urlparse, urlunparse, quote
|
|
|
|
| 142 |
@staticmethod
|
| 143 |
def forced_mode(user):
|
| 144 |
"""Optional per-account reliability mode; never forces every relay to burn CPU."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
users={x.strip().lower().lstrip('@') for x in
|
| 146 |
os.getenv('YOUTUBE_FORCE_TRANSCODE_USERS','').split(',') if x.strip()}
|
| 147 |
if str(user or '').lower().lstrip('@') not in users:
|
|
|
|
| 418 |
'status':s.get('status',''),'source':s.get('source',''),'output_seconds':s.get('output_seconds',0),
|
| 419 |
'send_mode':s.get('send_mode') or ev.get('sender_mode',''),
|
| 420 |
'pending_send_mode':s.get('pending_send_mode') or ev.get('sender_pending_mode',''),
|
|
|
|
|
|
|
| 421 |
# عمر آخر بايت من المصدر + حدّ الحارس: اللوحة تُظهر «المصدر صامت 12 ث» بدل أن
|
| 422 |
# يظن المشاهد أن البث ميت بينما الجلسة تعمل (ما حدث في تجمّد @viggoo313).
|
| 423 |
# صمت المصدر لحظة آخر قفل: يشرح في اللوحة سبب 187 (جوع تغذية لا رفض مستقبِل).
|