Spaces:
Sleeping
Sleeping
File size: 22,976 Bytes
d9a03db d7b1f0b d9a03db e98099c d9a03db e98099c d9a03db e98099c d9a03db e98099c d9a03db e98099c d9a03db e98099c d9a03db e98099c d9a03db 91ae573 d9a03db d0dc16f d7b1f0b d0dc16f d9a03db d7b1f0b d9a03db 81cc161 d9a03db 81cc161 d9a03db e98099c d9a03db e98099c 9fb93a9 d9a03db | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 | import os
import sys
# Setup dynamic CUDA paths before importing Whisper
nvidia_subdirs = [
("nvidia", "cublas", "lib"),
("nvidia", "cudnn", "lib"),
("nvidia", "cuda_nvrtc", "lib")
]
added_paths = []
for p in sys.path:
if "site-packages" in p:
for subdir in nvidia_subdirs:
full_path = os.path.join(p, *subdir)
if os.path.exists(full_path) and full_path not in added_paths:
added_paths.append(full_path)
if added_paths:
existing = os.environ.get("LD_LIBRARY_PATH", "")
if existing:
os.environ["LD_LIBRARY_PATH"] = ":".join(added_paths) + ":" + existing
else:
os.environ["LD_LIBRARY_PATH"] = ":".join(added_paths)
import time
import subprocess
import requests
import numpy as np
from dotenv import load_dotenv
from faster_whisper import WhisperModel
# Load local environment settings
load_dotenv()
API_URL = os.getenv("API_URL", "http://localhost:3000")
API_KEY = os.getenv("API_KEY", "")
TWITCH_CHANNEL = os.getenv("TWITCH_CHANNEL", "winx_prinx").lower()
WHISPER_MODEL_SIZE = os.getenv("WHISPER_MODEL", "base")
WHISPER_DEVICE = os.getenv("WHISPER_DEVICE", "cpu")
WHISPER_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "int8")
WHISPER_CPU_THREADS = int(os.getenv("WHISPER_CPU_THREADS", "2"))
if not API_KEY:
print("[Error] API_KEY is missing in .env! Cannot push data to backend.")
sys.exit(1)
headers = {
"x-api-key": API_KEY,
"Content-Type": "application/json"
}
# =========================================================================
# PUBLIC TWITCH GQL API CLIENT
# =========================================================================
def get_vod_details(vod_id):
"""Fetch VOD metadata using public Twitch GQL API"""
url = "https://gql.twitch.tv/gql"
gql_headers = {
"Client-Id": "kimne78kx3ncx6brgo4mv6wki5h1ko",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
payload = {
"query": f"""
query {{
video(id: "{vod_id}") {{
title
createdAt
lengthSeconds
owner {{
login
}}
}}
}}
"""
}
try:
res = requests.post(url, json=payload, headers=gql_headers, timeout=10)
if res.status_code == 200:
data = res.json()
return data.get("data", {}).get("video")
except Exception as e:
print(f"[Error] Failed to fetch VOD details: {e}")
return None
def download_vod_chat(vod_id, start_offset=0, end_offset=None):
"""Download chat log of a past VOD using GQL offset-based pagination (bypasses integrity checks)"""
comments = []
seen_ids = set()
url = "https://gql.twitch.tv/gql"
gql_headers = {
"Client-Id": "kimne78kx3ncx6brgo4mv6wki5h1ko",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
current_offset = start_offset
print(f"\n[Chat] Запуск скачивания чата для VOD {vod_id} с секунды {start_offset} до {'конца' if end_offset is None else str(end_offset) + 's'}...")
MAX_RETRIES = 5
with requests.Session() as session:
while True:
if end_offset is not None and current_offset >= end_offset:
print(f"\n[Chat] Достигнут конечный офсет {end_offset}s. Завершаем скачивание чата.")
break
payload = {
"operationName": "VideoCommentsByOffsetOrCursor",
"variables": {
"videoID": str(vod_id),
"contentOffsetSeconds": current_offset
},
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a"
}
}
}
success = False
for attempt in range(MAX_RETRIES):
try:
res = session.post(url, json=payload, headers=gql_headers, timeout=15)
if res.status_code == 429:
wait = 2 ** attempt
print(f"\n[Chat] Rate limited (429). Повтор через {wait}с...")
time.sleep(wait)
continue
if res.status_code != 200:
print(f"\n[Chat] Ошибка GQL статус {res.status_code}")
break
data = res.json()
if isinstance(data, list):
data = data[0]
video = data.get("data", {}).get("video", {})
if not video:
break
comments_edge = video.get("comments") or {}
edges = comments_edge.get("edges") or []
if not edges:
break
for edge in edges:
if not edge:
continue
node = edge.get("node")
if not node:
continue
msg_id = node.get("id")
if not msg_id or msg_id in seen_ids:
continue
commenter = node.get("commenter")
if not commenter:
continue
user = commenter.get("login")
if not user:
continue
display_name = commenter.get("displayName", user)
message = node.get("message")
if not message:
continue
fragments = message.get("fragments") or []
message_text = "".join([f.get("text", "") for f in fragments if f])
timestamp = node.get("createdAt")
# Badges parse
user_badges = message.get("userBadges") or []
badges = [b.get("setID") for b in user_badges if b]
is_mod = "moderator" in badges or "broadcaster" in badges
is_sub = "subscriber" in badges or "founder" in badges
is_vip = "vip" in badges
is_streamer = (user.lower() == TWITCH_CHANNEL)
seen_ids.add(msg_id)
comments.append({
"id": msg_id,
"username": user,
"displayName": display_name,
"message": message_text,
"timestamp": timestamp,
"isStreamer": is_streamer,
"isMod": is_mod,
"isSub": is_sub,
"isVip": is_vip
})
print(f"-> Загружено {len(comments)} комментариев...", end="\r")
# Progress offset
last_offset = edges[-1].get("node", {}).get("contentOffsetSeconds")
if last_offset is not None:
next_offset = last_offset + 1
if next_offset <= current_offset:
next_offset = current_offset + 30
current_offset = next_offset
else:
break
# Tiny sleep to avoid aggressive spamming
time.sleep(0.1)
success = True
break
except Exception as e:
wait = 2 ** attempt
print(f"\n[Chat] Ошибка при загрузке (попытка {attempt+1}/{MAX_RETRIES}): {e}")
if attempt < MAX_RETRIES - 1:
print(f"[Chat] Повтор через {wait}с...")
time.sleep(wait)
if not success:
print(f"\n[Chat] Не удалось продолжить скачивание из-за ошибок на offset {current_offset}.")
break
print(f"\n[Chat] Загрузка завершена. Всего сообщений: {len(comments)}")
return comments
# =========================================================================
# AUDIO TRANSCRIBER (WHISPER)
# =========================================================================
def transcribe_vod_audio(vod_id, start_time_iso, model, start_offset=0, end_offset=None):
"""Download VOD audio stream, seek to start_offset, transcribe with Whisper and upload"""
print(f"\n[Audio] Запуск скачивания и распознавания речи для VOD {vod_id} с секунды {start_offset} до {'конца' if end_offset is None else str(end_offset) + 's'}...")
# Check if streamlink is installed
try:
subprocess.run(["streamlink", "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except FileNotFoundError:
print("[Error] 'streamlink' utility is not installed! Cannot fetch VOD audio.")
return
cmd_streamlink = ["streamlink", f"twitch.tv/videos/{vod_id}", "audio,worst", "-O"]
if start_offset > 0:
h = start_offset // 3600
m = (start_offset % 3600) // 60
s = start_offset % 60
offset_str = f"{h:02d}:{m:02d}:{s:02d}"
cmd_streamlink.extend(["--hls-start-offset", offset_str])
print(f"[Audio] Запуск streamlink с поиском --hls-start-offset {offset_str}")
cmd_ffmpeg = ["ffmpeg", "-i", "pipe:0", "-ac", "1", "-ar", "16000", "-f", "s16le", "-"]
p_streamlink = None
p_ffmpeg = None
try:
p_streamlink = subprocess.Popen(cmd_streamlink, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
p_ffmpeg = subprocess.Popen(cmd_ffmpeg, stdin=p_streamlink.stdout, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
# 30-second audio chunks for batch processing (more efficient offline)
# 16000Hz * 2 bytes * 30 seconds = 960,000 bytes
chunk_seconds = 30
chunk_size = 16000 * 2 * chunk_seconds
offset_seconds = start_offset
try:
from datetime import datetime
clean_str = start_time_iso.replace('Z', '+00:00')
base_start_time = datetime.fromisoformat(clean_str).timestamp()
except Exception as parse_e:
print(f"[Audio] Error parsing start_time_iso '{start_time_iso}': {parse_e}")
# Fallback to seconds-only precision
clean_t = start_time_iso.split(".")[0].replace("Z", "").replace("+00:00", "")
base_start_time = time.mktime(time.strptime(clean_t, "%Y-%m-%dT%H:%M:%S"))
while True:
if end_offset is not None and offset_seconds >= end_offset:
print(f"\n[Audio] Достигнут конечный офсет {end_offset}s. Завершаем транскрибацию аудио.")
break
pcm_data = p_ffmpeg.stdout.read(chunk_size)
if not pcm_data:
break
# Convert raw 16-bit PCM bytes to float32 numpy array
audio_np = np.frombuffer(pcm_data, dtype=np.int16).astype(np.float32) / 32768.0
# Transcribe segment
segments, info = model.transcribe(
audio_np,
beam_size=5,
language="ru",
temperature=0.0,
condition_on_previous_text=False,
log_prob_threshold=-0.8,
no_speech_threshold=0.6,
vad_filter=True,
vad_parameters=dict(threshold=0.5, min_silence_duration_ms=500)
)
words_list = []
for segment in segments:
text = segment.text.strip()
if text:
words_list.extend(text.split())
if words_list:
# Calculate correct timestamp based on elapsed video time
word_time_epoch = base_start_time + offset_seconds
word_time_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(word_time_epoch))
# Send to backend
try:
url = f"{API_URL}/api/log/voice"
res = requests.post(url, json={"words": words_list, "timestamp": word_time_iso}, headers=headers, timeout=5)
if res.status_code == 200:
print(f"[{time.strftime('%H:%M:%S', time.gmtime(offset_seconds))}] Распознано и отправлено {len(words_list)} слов.")
except Exception as e:
print(f"[Sync Error] Не удалось отправить слова: {e}")
offset_seconds += chunk_seconds
except Exception as err:
print(f"[Audio Error] Ошибка конвейера аудио: {err}")
finally:
for p in [p_ffmpeg, p_streamlink]:
if p:
try:
p.terminate()
p.wait(timeout=2)
except:
try: p.kill()
except: pass
print("[Audio] Распознавание аудиодорожки завершено.")
# =========================================================================
# MAIN EXECUTION
# =========================================================================
if __name__ == "__main__":
print("=========================================================")
print(" Twitch VOD Backfiller & Speech Sync (winx_prinx) ")
print("=========================================================")
print(f"Target Channel: {TWITCH_CHANNEL}")
print(f"API Backend: {API_URL}")
print("=========================================================")
vod_id = input("Введите ID Twitch VOD (например, 2154382910): ").strip()
if not vod_id:
print("ID VOD не введен. Выход.")
sys.exit(0)
# 1. Fetch metadata
print("[Sync] Получение метаданных VOD...")
details = get_vod_details(vod_id)
if not details:
print("[Error] Не удалось получить информацию о VOD. Проверьте ID видео.")
sys.exit(1)
owner = details.get("owner", {}).get("login", "").lower()
if owner != TWITCH_CHANNEL:
print(f"[Warning] Владелец VOD ({owner}) не совпадает с целевым каналом ({TWITCH_CHANNEL})!")
confirm = input("Продолжить импорт? (y/n): ").strip().lower()
if confirm != 'y':
sys.exit(0)
title = details.get("title", "Архивный стрим")
created_at_raw = details.get("createdAt") # e.g. "2026-06-09T17:15:30Z"
length_seconds = details.get("lengthSeconds", 0)
# timezone-safe calculation using datetime
from datetime import datetime, timedelta
created_at = created_at_raw.replace("+00:00", "").replace("Z", "") + "Z"
dt_start = datetime.fromisoformat(created_at_raw.replace("Z", "+00:00"))
dt_end = dt_start + timedelta(seconds=length_seconds)
end_time = dt_end.strftime("%Y-%m-%dT%H:%M:%SZ")
print(f"\nНазвание: {title}")
print(f"Начало: {created_at}")
print(f"Длительность: {length_seconds // 3600}ч {(length_seconds % 3600) // 60}м")
# 2. Sync stream record in DB
print("\n[Sync] Регистрация сессии стрима на бэкенде...")
try:
url = f"{API_URL}/api/streams/sync-vod-direct"
payload = {
"twitchStreamId": f"vod-{vod_id}",
"title": title,
"category": "Архив",
"startTime": created_at,
"endTime": end_time
}
res = requests.post(url, json=payload, headers=headers, timeout=5)
if res.status_code != 200:
print(f"[Error] Не удалось синхронизировать сессию стрима: {res.status_code} - {res.text}")
sys.exit(1)
print("-> Сессия стрима успешно зарегистрирована в базе!")
except Exception as e:
print(f"[Error] Ошибка сети при обращении к бэкенду: {e}")
sys.exit(1)
# 3. Check for existing backfill status (resume/append support)
chat_start_offset = 0
voice_start_offset = 0
try:
status_url = f"{API_URL}/api/streams/backfill-status?twitchStreamId=vod-{vod_id}"
res = requests.get(status_url, headers=headers, timeout=5)
if res.status_code == 200:
status_data = res.json()
# Chat check
msg_count = status_data.get("messageCount", 0)
latest_msg_ts = status_data.get("latestMessageTimestamp")
if msg_count > 0 and latest_msg_ts:
from datetime import datetime
clean_ts = latest_msg_ts.replace('Z', '+00:00')
latest_epoch = datetime.fromisoformat(clean_ts).timestamp()
clean_created = created_at.replace('Z', '+00:00')
created_epoch = datetime.fromisoformat(clean_created).timestamp()
diff = int(latest_epoch - created_epoch)
if diff > 0:
chat_start_offset = diff
print(f"[Sync] Найден существующий чат в базе ({msg_count} сообщений). Возобновляем загрузку с секунды {chat_start_offset}...")
# Voice check
voice_count = status_data.get("voiceCount", 0)
latest_voice_ts = status_data.get("latestVoiceTimestamp")
if voice_count > 0 and latest_voice_ts:
from datetime import datetime
clean_ts = latest_voice_ts.replace('Z', '+00:00')
latest_epoch = datetime.fromisoformat(clean_ts).timestamp()
clean_created = created_at.replace('Z', '+00:00')
created_epoch = datetime.fromisoformat(clean_created).timestamp()
diff = int(latest_epoch - created_epoch)
if diff > 0:
voice_start_offset = (diff // 30) * 30
print(f"[Sync] Найден распознанный голос в базе ({voice_count} слов). Возобновляем транскрибацию с секунды {voice_start_offset}...")
except Exception as e:
print(f"[Sync Warning] Не удалось получить статус дозаписи: {e}")
# 4. Download and Upload Chat comments
chat_comments = download_vod_chat(vod_id, start_offset=chat_start_offset)
if chat_comments:
print("\n[Sync] Отправка чата на сервер (пакетами по 100 сообщений)...")
batch_size = 100
for i in range(0, len(chat_comments), batch_size):
batch = chat_comments[i:i+batch_size]
try:
url = f"{API_URL}/api/log/messages"
res = requests.post(url, json={"messages": batch}, headers=headers, timeout=5)
if res.status_code == 200:
print(f"-> Отправлено сообщений: {i + len(batch)} / {len(chat_comments)}", end="\r")
else:
print(f"\n-> Ошибка отправки пакета: {res.status_code}")
except Exception as e:
print(f"\n-> Ошибка сети при отправке пакета: {e}")
print("\n-> Синхронизация чата успешно завершена!")
# 5. Transcribe Audio
if voice_start_offset >= length_seconds:
print(f"\n[Sync] Голосовая дорожка уже полностью распознана (секунда {voice_start_offset} из {length_seconds}). Транскрибация не требуется.")
else:
prompt_str = f"\nХотите запустить скачивание и транскрибацию аудиодорожки (Whisper)? (y/n) [Продолжить с {voice_start_offset}s]: " if voice_start_offset > 0 else "\nХотите запустить скачивание и транскрибацию аудиодорожки (Whisper)? (y/n): "
transcribe_confirm = input(prompt_str).strip().lower()
if transcribe_confirm == 'y':
# Init Whisper
print(f"\n[Whisper] Загрузка модели {WHISPER_MODEL_SIZE}...")
try:
model = WhisperModel(
WHISPER_MODEL_SIZE,
device=WHISPER_DEVICE,
compute_type=WHISPER_COMPUTE_TYPE,
cpu_threads=WHISPER_CPU_THREADS
)
transcribe_vod_audio(vod_id, created_at, model, start_offset=voice_start_offset)
except Exception as e:
print(f"[Whisper Error] Не удалось инициализировать Whisper: {e}")
# 5. Mark Completed on Server
try:
url_mark = f"{API_URL}/api/streams/mark-backfilled"
requests.post(url_mark, json={"twitchStreamId": f"vod-{vod_id}"}, headers=headers, timeout=5)
print("-> Статус импорта успешно обновлен на сервере!")
except Exception as e:
print(f"[Warning] Не удалось отметить стрим как импортированный: {e}")
print("\n=========================================================")
print(" Импорт VOD завершен! ")
print("=========================================================")
|