This commit is contained in:
2026-05-13 09:37:42 -04:00
parent ca3469376a
commit 2889993a7d
40 changed files with 1583 additions and 0 deletions

View File

@@ -0,0 +1,223 @@
import socket
import time
import threading
import subprocess
from pathlib import Path
from loguru import logger
from collections import deque
from typing import Optional
from datetime import datetime
from twitchAPI.twitch import Twitch
from twitchAPI.oauth import UserAuthenticator
from twitchAPI.chat import Chat, ChatEvent, ChatMessage, EventData
from twitchAPI.type import AuthScope
from .models.errors import IRCError, UnexpectedError
class _TwitchClient:
async def __init__(self, channel: str, twitch_api: Twitch) -> None:
self._twitch_api = twitch_api
self._chat: Optional[Chat] = None
self._socket: Optional[socket.socket] = None
self._message_timestamps: deque[float] = deque()
self._start_time = datetime.now()
self._total_messages = 0
self._connected = False
self._channel = channel
self._failed_socket_attempts = 0
self._recording_thread: Optional[threading.Thread] = None
self._is_recording = False
self._should_stop_recording = False
self._current_vod_path: Optional[str] = None
self._recording_process: Optional[subprocess.Popen] = None
self._streamlink_process: Optional[subprocess.Popen] = None
async def _get_user_access_token(self):
auth = UserAuthenticator(self._twitch_api, [AuthScope.CHAT_READ])
token, refresh_token = await auth.authenticate() # type: ignore
await self._twitch_api.set_user_authentication(
token, [AuthScope.CHAT_READ], refresh_token
)
def get_connection_status(self):
return self._connected
def get_channel(self):
return self._channel
def get_total_messages(self):
return self._total_messages
def get_start_time(self):
return self._start_time
def start_recording_stream(self):
"""Start recording the stream from twitch"""
if self._is_recording:
raise RuntimeError("Recording is already in progress")
self._is_recording = True
self._should_stop_recording = False
self._recording_thread = threading.Thread(
target=self._record_stream_thread, daemon=True
)
self._recording_thread.start()
logger.info(f"Started recording stream for channel {self._channel}")
return True
async def connect(self):
"""High level connection function"""
logger.info(f"starting connection to Twitch channel {self._channel}")
try:
await self.irc_connect()
self.start_recording_stream()
logger.info(
f"successfully disconnected from twitch channel {self._channel}"
)
except Exception as e:
logger.error(f"failed to connect to twitch channel {self._channel}")
raise Exception(e)
def disconnect(self):
"""High level disconnect function"""
logger.info(f"starting disconnecting from twitch channel {self._channel}")
try:
self.irc_disconnect()
self.stop_recording_stream()
logger.info(
f"successfully disconnected from twitch channel {self._channel}"
)
except Exception as e:
logger.error(f"failed to disconnect from twitch channel {self._channel}")
raise Exception(e)
def stop_recording_stream(self):
"""Stops recording the stream from twitch"""
if not self._is_recording:
raise RuntimeError("No recording is in progress")
self._should_stop_recording = True
if self._streamlink_process:
logger.info("stopping stream link")
self._streamlink_process.kill()
self._streamlink_process = None
logger.info("success stopping stream link")
if self._recording_process:
logger.info("stopping ffmpeg")
self._recording_process.kill()
self._recording_process = None
logger.info("success stopping ffmpeg")
if self._recording_thread:
logger.info("waiting for recording thread to join back in")
self._recording_thread.join()
logger.info("threaded joined")
self._recording_thread = None
self._is_recording = False
self._recording_thread = None
logger.info(f"Stopped recording stream for channel {self._channel}")
return True
def _path(self):
return Path(f"vods/{self._channel}_{self._start_time}/")
def _vod_path(self):
vods_dir = self._path()
vods_dir.mkdir(exist_ok=True)
vod_filename = f"{self._channel}_{self._start_time}.mkv"
return vods_dir / vod_filename
def _message_path(self):
msg_dir = self._path()
msg_dir.mkdir(exist_ok=True)
return msg_dir / "messages.txt"
def _record_stream_thread(self):
twitch_url = f"https://twitch.tv/{self._channel}"
self._current_vod_path = str(self._vod_path())
logger.info(f"Recording to file: {self._current_vod_path}")
streamlink_cmd = [
"streamlink",
twitch_url,
"best",
"--stdout",
"--twitch-supported-codecs",
"av1,h265",
]
ffmpeg_cmd = [
"ffmpeg",
"-i",
"pipe:0",
"-vf",
"fps=24",
"-c:v",
"av1_nvenc",
"-preset",
"fast",
"-crf",
"23",
"-y",
self._current_vod_path,
]
self._streamlink_process = subprocess.Popen(
streamlink_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
self._recording_process = subprocess.Popen(
ffmpeg_cmd, stdin=self._streamlink_process.stdout, stderr=subprocess.PIPE
)
if self._streamlink_process.stdout:
self._streamlink_process.stdout.close()
while True:
if self._should_stop_recording:
logger.info("Stop signal received, terminating recording")
break
time.sleep(1)
async def irc_connect(self):
self._chat = await Chat(self._twitch_api)
self._chat.register_event(ChatEvent.READY, self._chat_on_ready)
self._chat.register_event(ChatEvent.MESSAGE, self._chat_on_message)
self._chat.start()
async def _chat_on_ready(self, ready_event: EventData):
await ready_event.chat.join_room(self._channel)
async def _chat_on_message(self, msg: ChatMessage):
self._write_messages(f"{msg.user.name}: {msg.text}: {msg.sent_timestamp}")
def irc_disconnect(self):
if self._chat is None:
return
self._chat.stop()
def _write_messages(self, message: str):
"""Takes a list of twitch irc messages"""
msg_path = self._message_path()
msg_path.touch(exist_ok=True)
with open(msg_path, "a") as f:
f.write(f"{message}\n")