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")