224 lines
6.9 KiB
Python
224 lines
6.9 KiB
Python
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")
|